From bfd0acdcf316c69e5ff4ac4f20d510c1e1d689a5 Mon Sep 17 00:00:00 2001 From: "manuel.maier" Date: Thu, 13 Aug 2026 23:27:12 +0200 Subject: [PATCH] Dateien nach "/" hochladen --- README.md | 159 ++--- Setup-SMTPGraphRelay.ps1 | 1270 ++++++++++++++++++++++++++++++-------- 2 files changed, 1061 insertions(+), 368 deletions(-) diff --git a/README.md b/README.md index 019205f..e06e8b1 100644 --- a/README.md +++ b/README.md @@ -1,137 +1,70 @@ -# SMTPGraphRelay V1.5 – Log-Rotation +# SMTPGraphRelay 1.5.0 -V1.5 erweitert V1.4 um automatische Log-Rotation und Log-Aufbewahrung. +Ab dieser Version gibt es **keinen `payload`-Ordner mehr**. -## Standardverhalten - -Ohne Änderungen an `config.json` gelten: +Alle Release-Dateien liegen genau einmal nebeneinander: ```text -MaxFileSizeMB = 10 -RetentionDays = 30 -CleanupHours = 12 +SMTPGraphRelay-v1.5.0\ +├── Setup-SMTPGraphRelay.ps1 +├── SMTPGraphRelay.ps1 +├── Test-SMTPGraphRelay.ps1 +├── Renew-SMTPGraphRelayCertificate.ps1 +└── version.json ``` -Das aktive Log bleibt: +## Warum + +Der Manager kopiert bei Neuinstallation, Repair und Update immer die Dateien, +die direkt neben `Setup-SMTPGraphRelay.ps1` liegen. + +Dadurch gibt es keine zweite Kopie von `SMTPGraphRelay.ps1`, die bei neuen +Versionen versehentlich veraltet bleiben könnte. + +## Update + +Ein neues Release wird künftig einfach als neuer Ordner/ZIP gebaut: ```text -logs\SMTPGraphRelay.log +SMTPGraphRelay-v1.6.0.zip ``` -Sobald es 10 MB erreicht, wird es z. B. umbenannt in: +Darin liegen jeweils genau die aktuellen Dateien. + +Dann: + +```powershell +.\Setup-SMTPGraphRelay.ps1 +``` + +und Menüpunkt: ```text -logs\SMTPGraphRelay-20260813-231500.log +[3] Relay aktualisieren ``` -Danach wird automatisch eine neue `SMTPGraphRelay.log` begonnen. +Der Manager liest `version.json` und zeigt: -Archiv-Logs, die älter als 30 Tage sind, werden automatisch entfernt. - -## Optionale Konfiguration - -Die bestehende Config funktioniert unverändert. - -Optional kann auf Root-Ebene ergänzt werden: - -```json -"Logging": { - "MaxFileSizeMB": 10, - "RetentionDays": 30, - "CleanupHours": 12 -} +```text +Paketversion: 1.6.0 +Installierte Version: 1.5.0 ``` +Beim Update wird die vorhandene `SMTPGraphRelay.ps1` weiterhin mit Zeitstempel +gesichert. `config.json`, Queue, Logs, Zertifikat, Entra-App und Exchange-RBAC +bleiben erhalten. + +## version.json + Beispiel: ```json { - "Smtp": { - "ListenAddress": "0.0.0.0", - "Port": 2525, - "Hostname": "SMTPRELAY01", - "AllowedNetworks": [ - "127.0.0.1/32", - "10.0.0.0/8" - ], - "MaxMessageSizeMB": 25, - "ClientTimeoutSeconds": 120, - "MaxConcurrentClients": 20 - }, - "Graph": { - "TenantId": "...", - "ClientId": "...", - "CertificateThumbprint": "...", - "SenderMailbox": "info@example.com", - "ForceSender": true - }, - "Queue": { - "PollSeconds": 10, - "MaxRetries": 8, - "RetryMinutes": [1,5,15,30,60,120,240,480] - }, - "Paths": { - "Queue": "queue", - "Failed": "failed", - "Logs": "logs" - }, - "Logging": { - "MaxFileSizeMB": 10, - "RetentionDays": 30, - "CleanupHours": 12 - } + "Version": "1.5.0", + "Product": "SMTPGraphRelay", + "MinimumPowerShell": "5.1", + "ReleaseDate": "2026-08-13" } ``` -## Parallel-sicher - -V1.5 verwendet weiterhin den benannten Mutex: - -```text -Local\SMTPGraphRelay-Log -``` - -Rotation und Schreiben passieren innerhalb desselben Locks. Dadurch können parallele -SMTP-Runspaces und der Queue-/Graph-Worker nicht gleichzeitig dieselbe Logdatei -rotieren oder beschreiben. - -## Update von V1.4 - -```powershell -Stop-ScheduledTask -TaskName "SMTPGraphRelay" - -Copy-Item ` - "C:\Program Files\SMTPGraphRelay\SMTPGraphRelay.ps1" ` - "C:\Program Files\SMTPGraphRelay\SMTPGraphRelay-V1.4-backup.ps1" -``` - -Dann V1.5 als: - -```text -C:\Program Files\SMTPGraphRelay\SMTPGraphRelay.ps1 -``` - -ablegen und: - -```powershell -Start-ScheduledTask -TaskName "SMTPGraphRelay" -``` - -Im Log sollte beim Start u. a. erscheinen: - -```text -SMTPGraphRelay V1.5 gestartet ... -Log-Rotation: max. 10 MB pro Datei, Aufbewahrung 30 Tage. -``` - -## Hinweis - -Nur archivierte Dateien im Muster - -```text -SMTPGraphRelay-*.log -``` - -werden automatisch gelöscht. - -Die aktuell aktive `SMTPGraphRelay.log` wird nie durch die Retention-Regel gelöscht. +Die Datei wird mit installiert und dient dem Manager als Versionsmarker. diff --git a/Setup-SMTPGraphRelay.ps1 b/Setup-SMTPGraphRelay.ps1 index 5ad41b5..54b10c0 100644 --- a/Setup-SMTPGraphRelay.ps1 +++ b/Setup-SMTPGraphRelay.ps1 @@ -2,57 +2,52 @@ #Requires -RunAsAdministrator <# .SYNOPSIS - First-Run Setup für SMTPGraphRelay. + SMTPGraphRelay Installer / Repair / Update .DESCRIPTION - - Installiert notwendige Microsoft Graph PowerShell Module - - Meldet interaktiv einen Entra-Administrator an - - Erstellt ein selbstsigniertes Zertifikat in LocalMachine\My - - Erstellt eine Entra ID App Registration - - Fügt das Zertifikat als Credential hinzu - - Vergibt Microsoft Graph Application Permission Mail.Send - - Erteilt Admin Consent über AppRoleAssignment - - Schreibt config.json - - Legt Firewallregel und Scheduled Task an + 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() +param( + [string]$InstallPath = "$env:ProgramFiles\SMTPGraphRelay" +) $ErrorActionPreference = "Stop" [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 -# ------------------------------------------------------------------------------ -# WICHTIG: Dieses Setup muss mit Windows PowerShell 5.1 gestartet werden. -# Der eigentliche SMTPGraphRelay Scheduled Task verwendet ebenfalls powershell.exe -# (Windows PowerShell 5.1). Wird das Setup stattdessen mit PowerShell 7 / pwsh.exe -# ausgeführt, können Microsoft.Graph-Module im falschen Modulpfad landen und sind -# später für den SYSTEM-Task unter Windows PowerShell 5.1 nicht sichtbar. -# ------------------------------------------------------------------------------ +$TaskName = "SMTPGraphRelay" +$AppDefaultName = "SMTPGraphRelay" +$RelayFileName = "SMTPGraphRelay.ps1" +$HealthFileName = "Test-SMTPGraphRelay.ps1" +$RenewFileName = "Renew-SMTPGraphRelayCertificate.ps1" +$ConfigFileName = "config.json" -if ($PSVersionTable.PSEdition -ne "Desktop" -or $PSVersionTable.PSVersion.Major -ne 5) { +function Write-Title { + param([string]$Text) Write-Host "" - Write-Host "==========================================================" -ForegroundColor Red - Write-Host " FALSCHE POWERSHELL-VERSION" -ForegroundColor Red - Write-Host "==========================================================" -ForegroundColor Red + Write-Host "==========================================================" -ForegroundColor Cyan + Write-Host " $Text" -ForegroundColor Cyan + Write-Host "==========================================================" -ForegroundColor Cyan Write-Host "" - Write-Host "Dieses Setup muss mit Windows PowerShell 5.1 ausgefuehrt werden." -ForegroundColor Yellow - Write-Host "" - Write-Host "Aktuell erkannt:" -ForegroundColor Cyan - Write-Host " Edition: $($PSVersionTable.PSEdition)" - Write-Host " Version: $($PSVersionTable.PSVersion)" - Write-Host "" - Write-Host "Bitte eine klassische Windows PowerShell oeffnen:" -ForegroundColor Cyan - Write-Host " C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe" - Write-Host "" - Write-Host "Danach das Setup dort erneut starten." -ForegroundColor Yellow - Write-Host "" - Read-Host "Enter druecken zum Beenden" - exit 1 } -Write-Host "Windows PowerShell erkannt: $($PSVersionTable.PSVersion)" -ForegroundColor Green -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) @@ -61,6 +56,300 @@ function Read-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 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" + + $action = New-ScheduledTaskAction ` + -Execute $psExe ` + -Argument "-NoLogo -NoProfile -ExecutionPolicy Bypass -File `"$scriptPath`"" ` + -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) @@ -74,261 +363,732 @@ function Convert-CertToKeyCredential { } } -Write-Host "==========================================================" -ForegroundColor Cyan -Write-Host " SMTPGraphRelay - First Run / Entra ID Setup" -ForegroundColor Cyan -Write-Host "==========================================================" -ForegroundColor Cyan -Write-Host "" +function New-RelayCertificate { + $subject = "CN=SMTPGraphRelay-$env:COMPUTERNAME" -Write-Host "Schritt 0: Voraussetzungen prüfen..." -ForegroundColor Cyan + return New-SelfSignedCertificate ` + -Subject $subject ` + -CertStoreLocation "Cert:\LocalMachine\My" ` + -KeyAlgorithm RSA ` + -KeyLength 2048 ` + -HashAlgorithm SHA256 ` + -KeyExportPolicy NonExportable ` + -KeySpec Signature ` + -NotAfter (Get-Date).AddYears(2) +} -$modules = @( - "Microsoft.Graph.Authentication", - "Microsoft.Graph.Applications" -) +function Connect-RelayGraphAdmin { + param([string]$TenantId) -foreach ($module in $modules) { - if (-not (Get-Module -ListAvailable -Name $module)) { - Write-Host "-> $module fehlt. Wird für alle Benutzer installiert..." -ForegroundColor Yellow - Install-Module $module -Scope AllUsers -Repository PSGallery -Force -AllowClobber + 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 } } -Import-Module Microsoft.Graph.Authentication -ErrorAction Stop -Import-Module Microsoft.Graph.Applications -ErrorAction Stop -Write-Host "-> Voraussetzungen erfüllt." -ForegroundColor Green -Write-Host "" +function Ensure-ExchangeRbac { + param( + [Parameter(Mandatory)][string]$TenantId, + [Parameter(Mandatory)][string]$ClientId, + [Parameter(Mandatory)][string]$ServicePrincipalObjectId, + [Parameter(Mandatory)][string]$AppName, + [Parameter(Mandatory)][string]$SenderMailbox + ) -Write-Host "Schritt 1: Relay-Konfiguration erfassen" -ForegroundColor Cyan + Import-Module ExchangeOnlineManagement -Force -ErrorAction Stop -$AppName = Read-Default "-> Name der Entra App" "SMTPGraphRelay" -$SenderMailbox = Read-Host "-> M365-Absenderpostfach (z.B. smtp-relay@firma.de)" -while ([string]::IsNullOrWhiteSpace($SenderMailbox) -or $SenderMailbox -notmatch '^[^@\s]+@[^@\s]+\.[^@\s]+$') { - $SenderMailbox = Read-Host "-> Bitte eine gültige Mailadresse eingeben" + 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 + } } -$ListenAddress = Read-Default "-> Lokale Listen-IP" "0.0.0.0" -$Port = [int](Read-Default "-> SMTP-Port" "2525") -$AllowedNetworksText = 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 = @($AllowedNetworksText -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) +function Test-NoGlobalMailSend { + param( + [Parameter(Mandatory)][string]$ServicePrincipalObjectId + ) -Write-Host "" -Write-Host "Schritt 2: Erzeuge Zertifikat für unbeaufsichtigte Graph-Anmeldung..." -ForegroundColor Cyan + # Microsoft Graph Service Principal + $graphSp = Get-MgServicePrincipal -Filter "appId eq '00000003-0000-0000-c000-000000000000'" -Property "id,appRoles" -$certSubject = "CN=SMTPGraphRelay-$env:COMPUTERNAME" -$cert = New-SelfSignedCertificate ` - -Subject $certSubject ` - -CertStoreLocation "Cert:\LocalMachine\My" ` - -KeyAlgorithm RSA ` - -KeyLength 2048 ` - -HashAlgorithm SHA256 ` - -KeyExportPolicy NonExportable ` - -KeySpec Signature ` - -NotAfter (Get-Date).AddYears(2) + if (-not $graphSp) { + Write-Warn "Microsoft Graph Service Principal konnte nicht geprüft werden." + return + } -Write-Host "-> Zertifikat erstellt: $($cert.Thumbprint)" -ForegroundColor Green -Write-Host "" + $mailSendRole = $graphSp.AppRoles | Where-Object { + $_.Value -eq "Mail.Send" -and $_.AllowedMemberTypes -contains "Application" + } | Select-Object -First 1 -Write-Host "Schritt 3: Mit Microsoft Graph anmelden..." -ForegroundColor Cyan -Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null + if (-not $mailSendRole) { + Write-Warn "Graph AppRole Mail.Send konnte nicht aufgelöst werden." + return + } -# Application.ReadWrite.All: App + Service Principal erstellen -# AppRoleAssignment.ReadWrite.All: Mail.Send als AppRoleAssignment (Admin Consent) erteilen -Connect-MgGraph -Scopes "Application.ReadWrite.All","AppRoleAssignment.ReadWrite.All" -NoWelcome + $assignments = Get-MgServicePrincipalAppRoleAssignment ` + -ServicePrincipalId $ServicePrincipalObjectId ` + -All ` + -ErrorAction SilentlyContinue -$TenantId = (Get-MgContext).TenantId -Write-Host "-> Verbunden mit Tenant: $TenantId" -ForegroundColor Green -Write-Host "" + $global = $assignments | Where-Object { + $_.ResourceId -eq $graphSp.Id -and $_.AppRoleId -eq $mailSendRole.Id + } -Write-Host "Schritt 4: Microsoft Graph Mail.Send Application Permission ermitteln..." -ForegroundColor Cyan + 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." + } +} -$GraphSp = Get-MgServicePrincipal -Filter "appId eq '00000003-0000-0000-c000-000000000000'" -if (-not $GraphSp) { throw "Microsoft Graph Service Principal wurde im Tenant nicht gefunden." } +function Install-New { + Write-Title "SMTPGraphRelay - Neuinstallation" -$MailSend = $GraphSp.AppRoles | Where-Object { - $_.Value -eq "Mail.Send" -and $_.AllowedMemberTypes -contains "Application" -} | Select-Object -First 1 + 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 + } + } -if (-not $MailSend) { throw "Graph Application Permission Mail.Send wurde nicht gefunden." } + Ensure-Modules -IncludeExchange + Copy-ProgramFiles -TargetPath $InstallPath -RequireRelay + Ensure-Directories -TargetPath $InstallPath -Write-Host "-> Mail.Send AppRole-ID: $($MailSend.Id)" -ForegroundColor Green -Write-Host "" + $packageVersion = Get-PackageVersion + if ($packageVersion -and $packageVersion.Version) { + Write-Ok "Installiere Paketversion $($packageVersion.Version)." + } -Write-Host "Schritt 5: Entra ID App Registration erstellen..." -ForegroundColor Cyan + $appName = Read-Default "Name der Entra App" $AppDefaultName -$AppParams = @{ - DisplayName = $AppName - SignInAudience = "AzureADMyOrg" - RequiredResourceAccess = @( - @{ - ResourceAppId = "00000003-0000-0000-c000-000000000000" - ResourceAccess = @( - @{ - Id = $MailSend.Id - Type = "Role" - } + $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) ) } - ) - 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)" + + # Keine globale Graph Mail.Send Permission! + 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 + } + } + + 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 + + Write-Title "Neuinstallation abgeschlossen" + Write-Host "Installationspfad: $InstallPath" + + $packageVersion = Get-PackageVersion + $installedVersion = Get-InstalledVersion -TargetPath $InstallPath + + if ($packageVersion -and $packageVersion.Version) { + Write-Host "Paketversion: $($packageVersion.Version)" + } + + if ($installedVersion -and $installedVersion.Version) { + Write-Host "Installierte Version: $($installedVersion.Version)" + } + elseif (Test-Path -LiteralPath (Join-Path $InstallPath $RelayFileName)) { + Write-Warn "Installierte Version unbekannt (keine version.json)." + } + 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 + } } -$App = New-MgApplication -BodyParameter $AppParams -Write-Host "-> App erstellt: $($App.AppId)" -ForegroundColor Green +function Repair-Installation { + Write-Title "SMTPGraphRelay - Repair" + + $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 + } + + Ensure-Modules + Copy-ProgramFiles -TargetPath $InstallPath + 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." + } + + Ensure-FirewallRule -Port ([int]$config.Smtp.Port) + Ensure-ScheduledTask -TargetPath $InstallPath + + Start-RelayTask + + $health = Join-Path $InstallPath $HealthFileName + if (Test-Path -LiteralPath $health) { + Write-Info "Starte Health Check..." + & $health -ConfigPath (Join-Path $InstallPath $ConfigFileName) + $healthExit = $LASTEXITCODE + Write-Info "Health Check ExitCode: $healthExit" + } + + Write-Title "Repair abgeschlossen" +} + +function Update-Relay { + Write-Title "SMTPGraphRelay - 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 + } + + $packageVersion = Get-PackageVersion + $installedVersion = Get-InstalledVersion -TargetPath $InstallPath + + if ($packageVersion -and $packageVersion.Version) { + Write-Info "Paketversion: $($packageVersion.Version)" + } + + if ($installedVersion -and $installedVersion.Version) { + Write-Info "Installierte Version: $($installedVersion.Version)" + + try { + $pkgVer = [version]$packageVersion.Version + $instVer = [version]$installedVersion.Version + + if ($pkgVer -lt $instVer) { + Write-Warn "Das Paket ist älter als die installierte Version." + if (-not (Confirm-Yes "Downgrade trotzdem durchführen?")) { + return + } + } + elseif ($pkgVer -eq $instVer) { + Write-Warn "Paket- und installierte Version sind identisch." + if (-not (Confirm-Yes "Version trotzdem erneut einspielen?")) { + return + } + } + } catch { + Write-Warn "Versionsvergleich konnte nicht durchgeführt werden." + } + } + + $sourceRelay = Get-SourceFile -Name $RelayFileName + if (-not $sourceRelay) { + Write-Fail "Kein neues '$RelayFileName' neben dem Installer gefunden." + return + } + + Stop-RelayTask + + $installedRelay = Join-Path $InstallPath $RelayFileName + if (Test-Path -LiteralPath $installedRelay) { + $backup = Join-Path $InstallPath ("SMTPGraphRelay-backup-{0}.ps1" -f (Get-Date -Format "yyyyMMdd-HHmmss")) + Copy-Item -LiteralPath $installedRelay -Destination $backup -Force + Write-Ok "Backup erstellt: $backup" + } + + Copy-ProgramFiles -TargetPath $InstallPath -RequireRelay + Ensure-Directories -TargetPath $InstallPath + Ensure-FirewallRule -Port ([int]$config.Smtp.Port) + Ensure-ScheduledTask -TargetPath $InstallPath + Start-RelayTask + + $health = Join-Path $InstallPath $HealthFileName + if (Test-Path -LiteralPath $health) { + Write-Info "Starte Health Check..." + & $health -ConfigPath (Join-Path $InstallPath $ConfigFileName) + $healthExit = $LASTEXITCODE + + if ($healthExit -eq 0) { + Write-Ok "Update-Health-Check erfolgreich." + } + elseif ($healthExit -eq 1) { + Write-Warn "Update abgeschlossen, Health Check enthält Warnungen." + } + else { + Write-Fail "Update eingespielt, Health Check meldet Fehler. Backup liegt im Installationsordner." + } + } + + Write-Title "Update abgeschlossen" +} + +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 -$Sp = $null -for ($i = 0; $i -lt 10 -and -not $Sp; $i++) { try { - $Sp = New-MgServicePrincipal -AppId $App.AppId - } catch { - Start-Sleep -Seconds 2 + $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 + } } -} -if (-not $Sp) { throw "Service Principal konnte nicht erstellt werden." } - -Write-Host "-> Service Principal erstellt: $($Sp.Id)" -ForegroundColor Green -Write-Host "" - -Write-Host "Schritt 6: Admin Consent für Mail.Send erteilen..." -ForegroundColor Cyan - -New-MgServicePrincipalAppRoleAssignment ` - -ServicePrincipalId $Sp.Id ` - -PrincipalId $Sp.Id ` - -ResourceId $GraphSp.Id ` - -AppRoleId $MailSend.Id | Out-Null - -Write-Host "-> Mail.Send wurde als Application Permission erteilt." -ForegroundColor Green -Write-Host "" - -Write-Host "Schritt 7: config.json schreiben..." -ForegroundColor Cyan - -$config = [ordered]@{ - Smtp = [ordered]@{ - ListenAddress = $ListenAddress - Port = $Port - Hostname = $env:COMPUTERNAME - AllowedNetworks = $AllowedNetworks - MaxMessageSizeMB = 25 - ClientTimeoutSeconds = 120 - } - Graph = [ordered]@{ - TenantId = $TenantId - ClientId = $App.AppId - CertificateThumbprint = $cert.Thumbprint - SenderMailbox = $SenderMailbox - ForceSender = $true - } - Queue = [ordered]@{ - PollSeconds = 10 - MaxRetries = 8 - RetryMinutes = @(1,5,15,30,60,120,240,480) - } - Paths = [ordered]@{ - Queue = "queue" - Failed = "failed" - Logs = "logs" + finally { + Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null } } -$configPath = Join-Path $PSScriptRoot "config.json" -$config | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $configPath -Encoding UTF8 +function Invoke-CertificateRenewal { + Write-Title "SMTPGraphRelay - Zertifikat erneuern" -foreach ($dir in @("queue","failed","logs")) { - New-Item -ItemType Directory -Path (Join-Path $PSScriptRoot $dir) -Force | Out-Null + $renew = Join-Path $InstallPath $RenewFileName + if (-not (Test-Path -LiteralPath $renew)) { + Write-Fail "$RenewFileName ist nicht installiert." + return + } + + & $renew -ConfigPath (Join-Path $InstallPath $ConfigFileName) } -Write-Host "-> Konfiguration: $configPath" -ForegroundColor Green -Write-Host "" +function Invoke-HealthCheck { + Write-Title "SMTPGraphRelay - Health Check" -Write-Host "Schritt 8: Windows Firewall konfigurieren..." -ForegroundColor Cyan -$ruleName = "SMTPGraphRelay TCP $Port" -Get-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue | Remove-NetFirewallRule -ErrorAction SilentlyContinue -New-NetFirewallRule ` - -DisplayName $ruleName ` - -Direction Inbound ` - -Action Allow ` - -Protocol TCP ` - -LocalPort $Port ` - -Profile Any | Out-Null -Write-Host "-> Firewallregel erstellt." -ForegroundColor Green -Write-Host "" + $health = Join-Path $InstallPath $HealthFileName + if (-not (Test-Path -LiteralPath $health)) { + Write-Fail "$HealthFileName ist nicht installiert." + return + } -Write-Host "Schritt 9: Autostart als Scheduled Task anlegen..." -ForegroundColor Cyan - -$taskName = "SMTPGraphRelay" -$scriptPath = Join-Path $PSScriptRoot "SMTPGraphRelay.ps1" -$psExe = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" - -$action = New-ScheduledTaskAction ` - -Execute $psExe ` - -Argument "-NoLogo -NoProfile -ExecutionPolicy Bypass -File `"$scriptPath`"" - -$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-Host "-> Scheduled Task '$taskName' erstellt." -ForegroundColor Green -Write-Host "" - -Write-Host "Schritt 10: Teste App-only Graph-Anmeldung..." -ForegroundColor Cyan -Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null -Connect-MgGraph ` - -TenantId $TenantId ` - -ClientId $App.AppId ` - -Certificate $cert ` - -NoWelcome | Out-Null - -$ctx = Get-MgContext -if ($ctx.AuthType -ne "AppOnly") { - throw "App-only Graph-Anmeldung konnte nicht bestätigt werden." -} -Write-Host "-> App-only Graph-Anmeldung funktioniert." -ForegroundColor Green -Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null -Write-Host "" - -Write-Host "==========================================================" -ForegroundColor Green -Write-Host " SMTPGraphRelay wurde eingerichtet." -ForegroundColor Green -Write-Host "==========================================================" -ForegroundColor Green -Write-Host "" -Write-Host " Tenant ID: $TenantId" -ForegroundColor Yellow -Write-Host " Client ID: $($App.AppId)" -ForegroundColor Yellow -Write-Host " Zertifikat: $($cert.Thumbprint)" -ForegroundColor Yellow -Write-Host " Graph-Absender: $SenderMailbox" -ForegroundColor Yellow -Write-Host " SMTP Listener: $ListenAddress`:$Port" -ForegroundColor Yellow -Write-Host "" -Write-Host "WICHTIG:" -ForegroundColor Yellow -Write-Host "Mail.Send als Application Permission ist tenantweit mächtig." -Write-Host "Diese V1 erzwingt lokal SenderMailbox='$SenderMailbox', begrenzt die" -Write-Host "Graph-Berechtigung selbst aber noch nicht per Exchange Application RBAC." -Write-Host "" -Write-Host "Task starten mit:" -ForegroundColor Cyan -Write-Host " Start-ScheduledTask -TaskName `"$taskName`"" -Write-Host "" -Write-Host "Logs:" -ForegroundColor Cyan -Write-Host " $(Join-Path $PSScriptRoot 'logs\SMTPGraphRelay.log')" -Write-Host "" - -$startNow = Read-Host "Relay jetzt starten? [J/n]" -if ([string]::IsNullOrWhiteSpace($startNow) -or $startNow -match '^(?i)j|ja|y|yes$') { - Start-ScheduledTask -TaskName $taskName - Start-Sleep -Seconds 2 - Write-Host "-> Task gestartet." -ForegroundColor Green + & $health -ConfigPath (Join-Path $InstallPath $ConfigFileName) } -Read-Host "Enter drücken zum Beenden" +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 - Installer / Repair / Update" + Write-Host "Installationspfad: $InstallPath" -ForegroundColor DarkGray + + $packageVersion = Get-PackageVersion + $installedVersion = Get-InstalledVersion -TargetPath $InstallPath + + if ($packageVersion -and $packageVersion.Version) { + Write-Host "Paketversion: $($packageVersion.Version)" -ForegroundColor DarkGray + } + + if ($installedVersion -and $installedVersion.Version) { + Write-Host "Installiert: $($installedVersion.Version)" -ForegroundColor DarkGray + } + Write-Host "" + Write-Host " [1] Neuinstallation" + Write-Host " [2] Installation reparieren" + Write-Host " [3] Relay aktualisieren" + 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 + } +}