From 50334ec1cbd8d0c9fb37fc6c126051dbf35610c4 Mon Sep 17 00:00:00 2001 From: "manuel.maier" Date: Fri, 14 Aug 2026 00:13:11 +0200 Subject: [PATCH] Dateien nach "/" hochladen --- SMTPGraphRelay.ps1 | 509 ++++++++- Setup-SMTPGraphRelay(4).ps1 | 1931 +++++++++++++++++++++++++++++++++++ Test-SMTPGraphRelay(1).ps1 | 674 ++++++++++++ 3 files changed, 3096 insertions(+), 18 deletions(-) create mode 100644 Setup-SMTPGraphRelay(4).ps1 create mode 100644 Test-SMTPGraphRelay(1).ps1 diff --git a/SMTPGraphRelay.ps1 b/SMTPGraphRelay.ps1 index 058c2f0..16a7312 100644 --- a/SMTPGraphRelay.ps1 +++ b/SMTPGraphRelay.ps1 @@ -7,7 +7,7 @@ Nimmt lokale SMTP-Mails an, speichert sie als .eml in einer Queue und sendet sie anschließend per Microsoft Graph sendMail mit App-only Zertifikatsauthentifizierung. - V1.6: Queue-ID/Received/Message-ID sowie SMTP-/Queue-Backpressure und Session-Limits + V1.7: SMTP AUTH LOGIN/PLAIN mit PBKDF2-SHA256, Benutzer-/Session-Schutz und V1.6 Queue-/Backpressure-Funktionen #> [CmdletBinding()] @@ -809,6 +809,319 @@ function Add-RelayMessageHeaders { return $result } + +function Test-AddressInList { + param( + [Parameter(Mandatory)][System.Net.IPAddress]$Address, + [object[]]$Entries + ) + + foreach ($entry in @($Entries)) { + if ($null -eq $entry) { continue } + + $value = [string]$entry + if ([string]::IsNullOrWhiteSpace($value)) { continue } + + if ($value -eq "*") { return $true } + + try { + if ($value -match '/') { + if (Test-IPv4InCidr -Address $Address -Cidr $value) { + return $true + } + } + elseif ([System.Net.IPAddress]::Parse($value).Equals($Address)) { + return $true + } + } + catch {} + } + + return $false +} + +function Get-SmtpAuthSettings { + $requireAuth = $false + $maxFailures = 5 + $allowUnauthenticatedNetworks = @() + $users = @() + + try { + if ($script:Config.Smtp.PSObject.Properties.Name -contains "RequireAuth") { + $requireAuth = [bool]$script:Config.Smtp.RequireAuth + } + + if ($script:Config.Smtp.PSObject.Properties.Name -contains "AuthMaxFailures") { + $v = [int]$script:Config.Smtp.AuthMaxFailures + if ($v -ge 1 -and $v -le 100) { + $maxFailures = $v + } + } + + if ($script:Config.Smtp.PSObject.Properties.Name -contains "AllowUnauthenticatedNetworks") { + $allowUnauthenticatedNetworks = @($script:Config.Smtp.AllowUnauthenticatedNetworks) + } + + if ($script:Config.Smtp.PSObject.Properties.Name -contains "AuthUsers") { + $users = @($script:Config.Smtp.AuthUsers) + } + } + catch {} + + return [pscustomobject]@{ + RequireAuth = $requireAuth + AuthMaxFailures = $maxFailures + AllowUnauthenticatedNetworks = $allowUnauthenticatedNetworks + Users = $users + } +} + +function Test-SmtpAuthenticationRequired { + param([Parameter(Mandatory)][System.Net.IPAddress]$Address) + + $settings = Get-SmtpAuthSettings + + if (-not $settings.RequireAuth) { + return $false + } + + if (Test-AddressInList -Address $Address -Entries $settings.AllowUnauthenticatedNetworks) { + return $false + } + + return $true +} + +function ConvertFrom-Base64Utf8 { + param([Parameter(Mandatory)][string]$Value) + + try { + $bytes = [Convert]::FromBase64String($Value) + return [Text.Encoding]::UTF8.GetString($bytes) + } + catch { + throw "Invalid Base64 data" + } +} + +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 Test-FixedTimeEquals { + param( + [Parameter(Mandatory)][byte[]]$A, + [Parameter(Mandatory)][byte[]]$B + ) + + if ($A.Length -ne $B.Length) { + return $false + } + + [int]$difference = 0 + for ($i = 0; $i -lt $A.Length; $i++) { + $difference = $difference -bor ($A[$i] -bxor $B[$i]) + } + + return ($difference -eq 0) +} + +function Test-SmtpCredentials { + param( + [Parameter(Mandatory)][string]$Username, + [Parameter(Mandatory)][string]$Password + ) + + if ([string]::IsNullOrWhiteSpace($Username)) { + return $false + } + + $settings = Get-SmtpAuthSettings + $user = $settings.Users | + Where-Object { ([string]$_.Username).Equals($Username, [StringComparison]::OrdinalIgnoreCase) } | + Select-Object -First 1 + + if (-not $user) { + # Bewusst keine Unterscheidung im SMTP-Result zwischen unbekanntem + # Benutzer und falschem Passwort. + return $false + } + + try { + $salt = [Convert]::FromBase64String([string]$user.Salt) + $expected = [Convert]::FromBase64String([string]$user.PasswordHash) + $iterations = [int]$user.Iterations + + if ($iterations -lt 10000 -or $expected.Length -lt 16 -or $salt.Length -lt 8) { + return $false + } + + $actual = Invoke-Pbkdf2Sha256 ` + -Password $Password ` + -Salt $salt ` + -Iterations $iterations ` + -Length $expected.Length + + return (Test-FixedTimeEquals -A $actual -B $expected) + } + catch { + return $false + } +} + +function Invoke-SmtpAuthLogin { + param( + [Parameter(Mandatory)][System.IO.StreamReader]$Reader, + [Parameter(Mandatory)][System.IO.StreamWriter]$Writer, + [string]$InitialResponse + ) + + try { + if ([string]::IsNullOrWhiteSpace($InitialResponse)) { + Write-SmtpLine $Writer "334 VXNlcm5hbWU6" + $encodedUser = $Reader.ReadLine() + if ($null -eq $encodedUser) { throw "Client disconnected during AUTH LOGIN" } + } + else { + $encodedUser = $InitialResponse + } + + $username = ConvertFrom-Base64Utf8 -Value $encodedUser + + Write-SmtpLine $Writer "334 UGFzc3dvcmQ6" + $encodedPassword = $Reader.ReadLine() + if ($null -eq $encodedPassword) { throw "Client disconnected during AUTH LOGIN" } + + $password = ConvertFrom-Base64Utf8 -Value $encodedPassword + + return [pscustomobject]@{ + Valid = (Test-SmtpCredentials -Username $username -Password $password) + Username = $username + ProtocolError = $false + } + } + catch { + return [pscustomobject]@{ + Valid = $false + Username = $null + ProtocolError = $true + } + } +} + +function Invoke-SmtpAuthPlain { + param( + [Parameter(Mandatory)][System.IO.StreamReader]$Reader, + [Parameter(Mandatory)][System.IO.StreamWriter]$Writer, + [string]$InitialResponse + ) + + try { + $encoded = $InitialResponse + + if ([string]::IsNullOrWhiteSpace($encoded)) { + Write-SmtpLine $Writer "334" + $encoded = $Reader.ReadLine() + if ($null -eq $encoded) { throw "Client disconnected during AUTH PLAIN" } + } + + $decoded = ConvertFrom-Base64Utf8 -Value $encoded + $parts = $decoded -split "`0", 3 + + if ($parts.Count -lt 3) { + throw "Invalid AUTH PLAIN payload" + } + + # RFC 4616: [authzid] NUL authcid NUL passwd + $username = $parts[1] + $password = $parts[2] + + return [pscustomobject]@{ + Valid = (Test-SmtpCredentials -Username $username -Password $password) + Username = $username + ProtocolError = $false + } + } + catch { + return [pscustomobject]@{ + Valid = $false + Username = $null + ProtocolError = $true + } + } +} + function Get-SmtpLimits { $maxRecipients = 50 $maxMessagesPerConnection = 25 @@ -933,6 +1246,8 @@ function Save-SmtpMessage { [Parameter(Mandatory)] [string]$RemoteAddress, + [string]$AuthenticatedUser, + [string]$QueueId ) @@ -961,6 +1276,7 @@ function Save-SmtpMessage { QueueId = $QueueId ReceivedUtc = [DateTime]::UtcNow.ToString("o") RemoteAddress = $RemoteAddress + AuthenticatedUser = $AuthenticatedUser EnvelopeFrom = $MailFrom EnvelopeRecipients = @($Recipients) RetryCount = 0 @@ -985,7 +1301,8 @@ function Save-SmtpMessage { throw } - Write-Log "[$QueueId] Mail angenommen | Von=$MailFrom | An=$($Recipients -join ', ') | Client=$RemoteAddress" + $authText = if ([string]::IsNullOrWhiteSpace($AuthenticatedUser)) { "unauthenticated" } else { $AuthenticatedUser } + Write-Log "[$QueueId] Mail angenommen | Von=$MailFrom | An=$($Recipients -join ', ') | Client=$RemoteAddress | Auth=$authText" return [pscustomobject]@{ QueueId = $QueueId Path = $pendingEml @@ -1016,6 +1333,7 @@ function Handle-SmtpClient { $writer.AutoFlush = $true Write-SmtpLine $writer "554 5.7.1 Client not allowed" } catch {} + $Client.Close() Write-Log "Client abgewiesen: $remoteIp" "WARN" return @@ -1023,8 +1341,22 @@ function Handle-SmtpClient { $stream = $Client.GetStream() $stream.ReadTimeout = [int]$script:Config.Smtp.ClientTimeoutSeconds * 1000 - $reader = New-Object System.IO.StreamReader($stream, [System.Text.Encoding]::UTF8, $true, 4096, $true) - $writer = New-Object System.IO.StreamWriter($stream, [System.Text.Encoding]::ASCII, 4096, $true) + + $reader = New-Object System.IO.StreamReader( + $stream, + [System.Text.Encoding]::UTF8, + $true, + 4096, + $true + ) + + $writer = New-Object System.IO.StreamWriter( + $stream, + [System.Text.Encoding]::ASCII, + 4096, + $true + ) + $writer.NewLine = "`r`n" $writer.AutoFlush = $true @@ -1032,6 +1364,14 @@ function Handle-SmtpClient { $recipients = New-Object System.Collections.Generic.List[string] $acceptedMessages = 0 $limits = Get-SmtpLimits + $authSettings = Get-SmtpAuthSettings + + $authenticated = $false + $authenticatedUser = $null + $authFailures = 0 + $authLocked = $false + $authRequired = Test-SmtpAuthenticationRequired -Address $remoteIp + $authAvailable = (@($authSettings.Users).Count -gt 0) Write-SmtpLine $writer ("220 {0} SMTPGraphRelay ready" -f $script:Config.Smtp.Hostname) @@ -1040,12 +1380,112 @@ function Handle-SmtpClient { $line = $reader.ReadLine() if ($null -eq $line) { break } - if ($line -match '^(?i)(EHLO|HELO)\s+(.+)$') { + if ($line -match '^(?i)EHLO\s+(.+)$') { Write-SmtpLine $writer ("250-{0}" -f $script:Config.Smtp.Hostname) Write-SmtpLine $writer ("250-SIZE {0}" -f ([int64]$script:Config.Smtp.MaxMessageSizeMB * 1024 * 1024)) + + if ($authAvailable) { + Write-SmtpLine $writer "250-AUTH LOGIN PLAIN" + } + Write-SmtpLine $writer "250 8BITMIME" } + elseif ($line -match '^(?i)HELO\s+(.+)$') { + Write-SmtpLine $writer ("250 {0}" -f $script:Config.Smtp.Hostname) + } + elseif ($line -match '^(?i)AUTH\s+LOGIN(?:\s+(\S+))?\s*$') { + if (-not $authAvailable) { + Write-SmtpLine $writer "504 5.7.4 Authentication mechanism unavailable" + continue + } + + if ($authenticated) { + Write-SmtpLine $writer "503 5.5.0 Already authenticated" + continue + } + + if ($authLocked) { + Write-SmtpLine $writer "454 4.7.0 Too many authentication failures" + continue + } + + $initial = $matches[1] + $result = Invoke-SmtpAuthLogin -Reader $reader -Writer $writer -InitialResponse $initial + + if ($result.ProtocolError) { + $authFailures++ + Write-SmtpLine $writer "501 5.5.2 Invalid authentication data" + } + elseif ($result.Valid) { + $authenticated = $true + $authenticatedUser = $result.Username + $authFailures = 0 + Write-SmtpLine $writer "235 2.7.0 Authentication successful" + Write-Log ("SMTP-AUTH LOGIN erfolgreich | Client={0} | Benutzer={1}" -f $remoteIp, $authenticatedUser) + continue + } + else { + $authFailures++ + Write-SmtpLine $writer "535 5.7.8 Authentication credentials invalid" + Write-Log ("SMTP-AUTH LOGIN fehlgeschlagen | Client={0} | Benutzer={1}" -f $remoteIp, $result.Username) "WARN" + } + + if ($authFailures -ge $authSettings.AuthMaxFailures) { + $authLocked = $true + Write-Log ("SMTP-AUTH gesperrt nach {0} Fehlversuchen | Client={1}" -f $authFailures, $remoteIp) "WARN" + } + } + elseif ($line -match '^(?i)AUTH\s+PLAIN(?:\s+(\S+))?\s*$') { + if (-not $authAvailable) { + Write-SmtpLine $writer "504 5.7.4 Authentication mechanism unavailable" + continue + } + + if ($authenticated) { + Write-SmtpLine $writer "503 5.5.0 Already authenticated" + continue + } + + if ($authLocked) { + Write-SmtpLine $writer "454 4.7.0 Too many authentication failures" + continue + } + + $initial = $matches[1] + $result = Invoke-SmtpAuthPlain -Reader $reader -Writer $writer -InitialResponse $initial + + if ($result.ProtocolError) { + $authFailures++ + Write-SmtpLine $writer "501 5.5.2 Invalid authentication data" + } + elseif ($result.Valid) { + $authenticated = $true + $authenticatedUser = $result.Username + $authFailures = 0 + Write-SmtpLine $writer "235 2.7.0 Authentication successful" + Write-Log ("SMTP-AUTH PLAIN erfolgreich | Client={0} | Benutzer={1}" -f $remoteIp, $authenticatedUser) + continue + } + else { + $authFailures++ + Write-SmtpLine $writer "535 5.7.8 Authentication credentials invalid" + Write-Log ("SMTP-AUTH PLAIN fehlgeschlagen | Client={0} | Benutzer={1}" -f $remoteIp, $result.Username) "WARN" + } + + if ($authFailures -ge $authSettings.AuthMaxFailures) { + $authLocked = $true + Write-Log ("SMTP-AUTH gesperrt nach {0} Fehlversuchen | Client={1}" -f $authFailures, $remoteIp) "WARN" + } + } + elseif ($line -match '^(?i)AUTH\b') { + Write-SmtpLine $writer "504 5.5.4 Unsupported authentication mechanism" + } elseif ($line -match '^(?i)MAIL FROM:\s*<([^>]*)>') { + if ($authRequired -and -not $authenticated) { + Write-SmtpLine $writer "530 5.7.0 Authentication required" + continue + } + if ($acceptedMessages -ge $limits.MaxMessagesPerConnection) { Write-SmtpLine $writer "452 4.5.3 Too many messages in this session" Write-Log ("SMTP-Session von {0}: Nachrichtenlimit {1} erreicht." -f $remoteIp, $limits.MaxMessagesPerConnection) "WARN" @@ -1072,6 +1512,11 @@ function Handle-SmtpClient { Write-SmtpLine $writer "250 2.1.5 OK" } elseif ($line -match '^(?i)DATA\s*$') { + if ($authRequired -and -not $authenticated) { + Write-SmtpLine $writer "530 5.7.0 Authentication required" + continue + } + if (-not $mailFrom -or $recipients.Count -eq 0) { Write-SmtpLine $writer "503 5.5.1 Need MAIL FROM and RCPT TO first" continue @@ -1082,8 +1527,6 @@ function Handle-SmtpClient { continue } - # Backpressure VOR 354/DATA: Der Client soll große Nachrichtendaten - # gar nicht erst übertragen, wenn wir sie nicht sicher puffern können. $pressure = Get-QueuePressure if (-not $pressure.Accept) { Write-SmtpLine $writer $pressure.SmtpCode @@ -1092,6 +1535,7 @@ function Handle-SmtpClient { } Write-SmtpLine $writer "354 End data with ." + $data = New-Object System.Collections.Generic.List[string] $size = 0 $maxBytes = [int64]$script:Config.Smtp.MaxMessageSizeMB * 1024 * 1024 @@ -1099,13 +1543,21 @@ function Handle-SmtpClient { while ($true) { $dataLine = $reader.ReadLine() - if ($null -eq $dataLine) { throw "Client disconnected during DATA" } - if ($dataLine -eq ".") { break } - # SMTP dot-stuffing rückgängig machen - if ($dataLine.StartsWith("..")) { $dataLine = $dataLine.Substring(1) } + if ($null -eq $dataLine) { + throw "Client disconnected during DATA" + } + + if ($dataLine -eq ".") { + break + } + + if ($dataLine.StartsWith("..")) { + $dataLine = $dataLine.Substring(1) + } $size += [System.Text.Encoding]::UTF8.GetByteCount($dataLine) + 2 + if ($size -gt $maxBytes) { $tooLarge = $true } @@ -1127,17 +1579,24 @@ function Handle-SmtpClient { -MailFrom $mailFrom ` -Recipients $recipients.ToArray() ` -RemoteAddress $remoteIp.ToString() ` + -AuthenticatedUser $authenticatedUser ` -QueueId $queueId $acceptedMessages++ - Write-SmtpLine $writer ("250 2.0.0 Message accepted for delivery; queue-id={0}" -f $saved.QueueId) + + Write-SmtpLine $writer ( + "250 2.0.0 Message accepted for delivery; queue-id={0}" -f $saved.QueueId + ) } catch { - # Nach DATA darf niemals 250 gesendet werden, wenn die Queue-Datei - # nicht vollständig und atomar gesichert werden konnte. Write-SmtpLine $writer "451 4.3.0 SMTPGraphRelay queue temporarily unavailable" - Write-Log ("[{0}] Queue-Speicherung fehlgeschlagen | Client={1} | Fehler={2}" -f ` - $queueId, $remoteIp, $_.Exception.Message) "ERROR" + + Write-Log ( + "[{0}] Queue-Speicherung fehlgeschlagen | Client={1} | Fehler={2}" -f ` + $queueId, + $remoteIp, + $_.Exception.Message + ) "ERROR" } } @@ -1156,7 +1615,7 @@ function Handle-SmtpClient { Write-SmtpLine $writer "221 2.0.0 Bye" break } - elseif ($line -match '^(?i)(AUTH|STARTTLS)\b') { + elseif ($line -match '^(?i)STARTTLS\b') { Write-SmtpLine $writer "502 5.5.1 Command not implemented" } else { @@ -1424,6 +1883,15 @@ $smtpFunctionNames = @( "Get-QueueDirectories", "New-QueueId", "Add-RelayMessageHeaders", + "Test-AddressInList", + "Get-SmtpAuthSettings", + "Test-SmtpAuthenticationRequired", + "ConvertFrom-Base64Utf8", + "Invoke-Pbkdf2Sha256", + "Test-FixedTimeEquals", + "Test-SmtpCredentials", + "Invoke-SmtpAuthLogin", + "Invoke-SmtpAuthPlain", "Get-SmtpLimits", "Get-QueuePressure", "Save-SmtpMessage", @@ -1505,7 +1973,7 @@ $listenIp = [System.Net.IPAddress]::Parse($script:Config.Smtp.ListenAddress) $listener = [System.Net.Sockets.TcpListener]::new($listenIp, [int]$script:Config.Smtp.Port) $listener.Start() -Write-Log "SMTPGraphRelay V1.6 gestartet auf $($script:Config.Smtp.ListenAddress):$($script:Config.Smtp.Port)" +Write-Log "SMTPGraphRelay V1.7 gestartet auf $($script:Config.Smtp.ListenAddress):$($script:Config.Smtp.Port)" Write-Log "Graph-Absender: $($script:Config.Graph.SenderMailbox)" Write-Log "Maximale parallele SMTP-Verbindungen: $script:MaxConcurrentClients" Write-Log "Queue-/Graph-Worker läuft separat vom SMTP-Listener." @@ -1516,6 +1984,11 @@ Write-Log ("SMTP-Limits: max. {0} Empfänger/Mail, {1} Mails/Verbindung." -f ` Write-Log ("Backpressure: max. {0} Pending-Mails, mindestens {1} MB freier Speicher." -f ` $limits.MaxPendingMessages, $limits.MinFreeDiskSpaceMB) +$authSettings = Get-SmtpAuthSettings +$authMode = if ($authSettings.RequireAuth) { "erforderlich" } else { "optional/deaktiviert" } +Write-Log ("SMTP-AUTH: {0}; {1} Benutzer; max. {2} Fehlversuche/Verbindung." -f ` + $authMode, @($authSettings.Users).Count, $authSettings.AuthMaxFailures) + $logSettings = Get-LogSettings Write-Log ("Log-Rotation: max. {0} MB pro Datei, Aufbewahrung {1} Tage." -f ` $logSettings.MaxFileSizeMB, $logSettings.RetentionDays) diff --git a/Setup-SMTPGraphRelay(4).ps1 b/Setup-SMTPGraphRelay(4).ps1 new file mode 100644 index 0000000..f4e843e --- /dev/null +++ b/Setup-SMTPGraphRelay(4).ps1 @@ -0,0 +1,1931 @@ +#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 + } +} diff --git a/Test-SMTPGraphRelay(1).ps1 b/Test-SMTPGraphRelay(1).ps1 new file mode 100644 index 0000000..c7872c7 --- /dev/null +++ b/Test-SMTPGraphRelay(1).ps1 @@ -0,0 +1,674 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Health Check für SMTPGraphRelay. + +.DESCRIPTION + Prüft die lokale SMTPGraphRelay-Installation ohne Änderungen vorzunehmen. + + Standardprüfungen: + - Windows PowerShell 5.1 + - Administratorstatus + - config.json + - Microsoft.Graph.Authentication Modul + - Zertifikat + Private Key + Ablaufdatum + - Verzeichnisse und Schreibrechte + - Scheduled Task + - SMTP Listener + - Queue / Failed Queue + - App-only Microsoft Graph Anmeldung + + Optional: + - echte SMTP-Testmail über das lokale Relay + +.EXAMPLE + .\Test-SMTPGraphRelay.ps1 + +.EXAMPLE + .\Test-SMTPGraphRelay.ps1 -SendTestMail -TestRecipient manuel.maier@maieredv.de +#> + +[CmdletBinding()] +param( + [string]$ConfigPath = "$PSScriptRoot\config.json", + + [switch]$SendTestMail, + + [string]$TestRecipient, + + [string]$SmtpUsername, + + [int]$QueueWarningAgeMinutes = 30, + + [int]$FailedWarningCount = 1 +) + +$ErrorActionPreference = "Stop" +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + +$script:OkCount = 0 +$script:WarnCount = 0 +$script:FailCount = 0 + +function Write-Result { + param( + [Parameter(Mandatory)] + [ValidateSet("OK","WARN","FAIL","INFO")] + [string]$Status, + + [Parameter(Mandatory)] + [string]$Message + ) + + switch ($Status) { + "OK" { + $script:OkCount++ + Write-Host "[OK] $Message" -ForegroundColor Green + } + "WARN" { + $script:WarnCount++ + Write-Host "[WARN] $Message" -ForegroundColor Yellow + } + "FAIL" { + $script:FailCount++ + Write-Host "[FAIL] $Message" -ForegroundColor Red + } + "INFO" { + Write-Host "[INFO] $Message" -ForegroundColor Cyan + } + } +} + +function Resolve-ConfigPath { + param([Parameter(Mandatory)][string]$Path) + + if ([IO.Path]::IsPathRooted($Path)) { + return $Path + } + + return Join-Path $PSScriptRoot $Path +} + +function Test-IsAdministrator { + try { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = New-Object Security.Principal.WindowsPrincipal($identity) + return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + } + catch { + return $false + } +} + +function Test-DirectoryWritable { + param([Parameter(Mandatory)][string]$Path) + + try { + if (-not (Test-Path -LiteralPath $Path)) { + return $false + } + + $testFile = Join-Path $Path (".healthcheck-{0}.tmp" -f [guid]::NewGuid().ToString("N")) + [IO.File]::WriteAllText($testFile, "SMTPGraphRelay health check") + Remove-Item -LiteralPath $testFile -Force + return $true + } + catch { + return $false + } +} + +function Send-RawSmtpTestMail { + param( + [Parameter(Mandatory)][string]$Server, + [Parameter(Mandatory)][int]$Port, + [Parameter(Mandatory)][string]$From, + [Parameter(Mandatory)][string]$To, + [string]$Username, + [string]$Password + ) + + $client = New-Object System.Net.Sockets.TcpClient + + try { + $connect = $client.BeginConnect($Server, $Port, $null, $null) + + if (-not $connect.AsyncWaitHandle.WaitOne(5000)) { + throw "Timeout beim Verbindungsaufbau zu $Server`:$Port" + } + + $client.EndConnect($connect) + + $stream = $client.GetStream() + $stream.ReadTimeout = 5000 + $stream.WriteTimeout = 5000 + + $reader = New-Object System.IO.StreamReader($stream, [System.Text.Encoding]::ASCII) + $writer = New-Object System.IO.StreamWriter($stream, [System.Text.Encoding]::ASCII) + $writer.NewLine = "`r`n" + $writer.AutoFlush = $true + + function Read-SmtpResponse { + param([int[]]$ExpectedCodes) + + $lines = New-Object System.Collections.Generic.List[string] + + while ($true) { + $line = $reader.ReadLine() + + if ($null -eq $line) { + throw "SMTP-Verbindung unerwartet geschlossen." + } + + $lines.Add($line) + + if ($line -match '^(\d{3})([ -])') { + $code = [int]$matches[1] + $separator = $matches[2] + + if ($separator -eq " ") { + if ($ExpectedCodes -notcontains $code) { + throw "Unerwartete SMTP-Antwort: $($lines -join ' | ')" + } + + return ($lines -join " | ") + } + } + } + } + + [void](Read-SmtpResponse -ExpectedCodes @(220)) + + $writer.WriteLine("EHLO localhost") + [void](Read-SmtpResponse -ExpectedCodes @(250)) + + if (-not [string]::IsNullOrWhiteSpace($Username)) { + $writer.WriteLine("AUTH LOGIN") + [void](Read-SmtpResponse -ExpectedCodes @(334)) + + $writer.WriteLine([Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($Username))) + [void](Read-SmtpResponse -ExpectedCodes @(334)) + + $writer.WriteLine([Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($Password))) + [void](Read-SmtpResponse -ExpectedCodes @(235)) + } + + $writer.WriteLine("MAIL FROM:<$From>") + [void](Read-SmtpResponse -ExpectedCodes @(250)) + + $writer.WriteLine("RCPT TO:<$To>") + [void](Read-SmtpResponse -ExpectedCodes @(250)) + + $writer.WriteLine("DATA") + [void](Read-SmtpResponse -ExpectedCodes @(354)) + + $subject = "SMTPGraphRelay Health Check $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" + + $writer.WriteLine("From: <$From>") + $writer.WriteLine("To: <$To>") + $writer.WriteLine("Subject: $subject") + $writer.WriteLine("Date: $([DateTime]::Now.ToString('ddd, dd MMM yyyy HH:mm:ss zzz', [Globalization.CultureInfo]::InvariantCulture))") + $writer.WriteLine("Message-ID: <$([guid]::NewGuid().ToString('N'))@smtpgraphrelay-healthcheck>") + $writer.WriteLine("MIME-Version: 1.0") + $writer.WriteLine("Content-Type: text/plain; charset=utf-8") + $writer.WriteLine("") + $writer.WriteLine("SMTPGraphRelay Health Check") + $writer.WriteLine("") + $writer.WriteLine("Zeit: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')") + $writer.WriteLine("Host: $env:COMPUTERNAME") + $writer.WriteLine(".") + [void](Read-SmtpResponse -ExpectedCodes @(250)) + + $writer.WriteLine("QUIT") + [void](Read-SmtpResponse -ExpectedCodes @(221)) + + return $true + } + finally { + try { $client.Close() } catch {} + } +} + +Write-Host "" +Write-Host "==========================================================" -ForegroundColor Cyan +Write-Host " SMTPGraphRelay - Health Check" -ForegroundColor Cyan +Write-Host "==========================================================" -ForegroundColor Cyan +Write-Host "" + +# --------------------------------------------------------------------------- +# PowerShell / Rechte +# --------------------------------------------------------------------------- +if ($PSVersionTable.PSEdition -eq "Desktop" -and $PSVersionTable.PSVersion.Major -eq 5) { + Write-Result OK "Windows PowerShell $($PSVersionTable.PSVersion) erkannt." +} +else { + Write-Result FAIL "Dieses Tool sollte mit Windows PowerShell 5.1 laufen. Erkannt: $($PSVersionTable.PSEdition) $($PSVersionTable.PSVersion)" +} + +if (Test-IsAdministrator) { + Write-Result OK "PowerShell läuft als Administrator." +} +else { + Write-Result WARN "PowerShell läuft nicht als Administrator. Einige Prüfungen können eingeschränkt sein." +} + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- +if (-not (Test-Path -LiteralPath $ConfigPath)) { + Write-Result FAIL "config.json nicht gefunden: $ConfigPath" + Write-Host "" + Write-Host "Health Check abgebrochen, da ohne Config keine weiteren Prüfungen möglich sind." -ForegroundColor Red + exit 2 +} + +try { + $config = Get-Content -LiteralPath $ConfigPath -Raw -Encoding UTF8 | ConvertFrom-Json + Write-Result OK "config.json konnte gelesen und geparst werden." +} +catch { + Write-Result FAIL "config.json ist ungültig: $($_.Exception.Message)" + exit 2 +} + +$requiredConfig = @( + "Smtp.ListenAddress", + "Smtp.Port", + "Graph.TenantId", + "Graph.ClientId", + "Graph.CertificateThumbprint", + "Graph.SenderMailbox", + "Paths.Queue", + "Paths.Failed", + "Paths.Logs" +) + +$configMissing = $false + +foreach ($item in $requiredConfig) { + $parts = $item -split '\.' + $value = $config + + foreach ($part in $parts) { + if ($null -eq $value -or -not ($value.PSObject.Properties.Name -contains $part)) { + $value = $null + break + } + + $value = $value.$part + } + + if ($null -eq $value -or ([string]$value).Trim().Length -eq 0) { + Write-Result FAIL "Config-Wert fehlt: $item" + $configMissing = $true + } +} + +if (-not $configMissing) { + Write-Result OK "Alle erforderlichen Config-Werte sind vorhanden." +} + +# --------------------------------------------------------------------------- +# SMTP-AUTH Config +# --------------------------------------------------------------------------- +$authRequired = $false +$authUsers = @() + +if ($config.Smtp.PSObject.Properties.Name -contains "RequireAuth") { + $authRequired = [bool]$config.Smtp.RequireAuth +} + +if ($config.Smtp.PSObject.Properties.Name -contains "AuthUsers") { + $authUsers = @($config.Smtp.AuthUsers) +} + +if ($authRequired -and $authUsers.Count -eq 0) { + Write-Result FAIL "SMTP-AUTH ist erforderlich, aber es sind keine AuthUsers konfiguriert." +} +elseif ($authRequired) { + Write-Result OK "SMTP-AUTH ist erforderlich; $($authUsers.Count) Benutzer konfiguriert." +} +elseif ($authUsers.Count -gt 0) { + Write-Result OK "SMTP-AUTH ist optional; $($authUsers.Count) Benutzer konfiguriert." +} +else { + Write-Result INFO "SMTP-AUTH ist nicht erforderlich und es sind keine Benutzer konfiguriert." +} + +foreach ($authUser in $authUsers) { + $valid = $true + + if ([string]::IsNullOrWhiteSpace([string]$authUser.Username)) { $valid = $false } + if ([string]::IsNullOrWhiteSpace([string]$authUser.Salt)) { $valid = $false } + if ([string]::IsNullOrWhiteSpace([string]$authUser.PasswordHash)) { $valid = $false } + + try { + if ([int]$authUser.Iterations -lt 10000) { $valid = $false } + [void][Convert]::FromBase64String([string]$authUser.Salt) + [void][Convert]::FromBase64String([string]$authUser.PasswordHash) + } + catch { + $valid = $false + } + + if (-not $valid) { + Write-Result FAIL "SMTP-AUTH Benutzer '$($authUser.Username)' besitzt ungültige Hash-/Salt-Daten." + } +} + +# --------------------------------------------------------------------------- +# Module +# --------------------------------------------------------------------------- +$graphModule = Get-Module -ListAvailable -Name Microsoft.Graph.Authentication | + Sort-Object Version -Descending | + Select-Object -First 1 + +if ($graphModule) { + Write-Result OK "Microsoft.Graph.Authentication $($graphModule.Version) gefunden: $($graphModule.ModuleBase)" +} +else { + Write-Result FAIL "Microsoft.Graph.Authentication ist nicht installiert." +} + +# --------------------------------------------------------------------------- +# Zertifikat +# --------------------------------------------------------------------------- +$cert = $null +$certPath = "Cert:\LocalMachine\My\$($config.Graph.CertificateThumbprint)" + +try { + $cert = Get-Item -LiteralPath $certPath -ErrorAction Stop + Write-Result OK "Relay-Zertifikat gefunden: $($cert.Thumbprint)" + + if ($cert.HasPrivateKey) { + Write-Result OK "Zertifikat besitzt einen privaten Schlüssel." + } + else { + Write-Result FAIL "Zertifikat besitzt keinen privaten Schlüssel." + } + + $daysRemaining = [Math]::Floor(($cert.NotAfter.ToUniversalTime() - [DateTime]::UtcNow).TotalDays) + + if ($daysRemaining -lt 0) { + Write-Result FAIL "Zertifikat ist abgelaufen seit $($cert.NotAfter)." + } + elseif ($daysRemaining -le 14) { + Write-Result FAIL "Zertifikat läuft in $daysRemaining Tagen ab ($($cert.NotAfter))." + } + elseif ($daysRemaining -le 60) { + Write-Result WARN "Zertifikat läuft in $daysRemaining Tagen ab ($($cert.NotAfter))." + } + else { + Write-Result OK "Zertifikat gültig bis $($cert.NotAfter) ($daysRemaining Tage verbleibend)." + } +} +catch { + Write-Result FAIL "Relay-Zertifikat nicht gefunden oder nicht lesbar: $($_.Exception.Message)" +} + +# --------------------------------------------------------------------------- +# Pfade / Queue +# --------------------------------------------------------------------------- +$queueRoot = Resolve-ConfigPath $config.Paths.Queue +$failedDir = Resolve-ConfigPath $config.Paths.Failed +$logsDir = Resolve-ConfigPath $config.Paths.Logs + +$queueIncoming = Join-Path $queueRoot "incoming" +$queuePending = Join-Path $queueRoot "pending" +$queueProcessing = Join-Path $queueRoot "processing" + +foreach ($entry in @( + @{ Name = "Queue Root"; Path = $queueRoot }, + @{ Name = "Queue incoming"; Path = $queueIncoming }, + @{ Name = "Queue pending"; Path = $queuePending }, + @{ Name = "Queue processing"; Path = $queueProcessing }, + @{ Name = "Failed"; Path = $failedDir }, + @{ Name = "Logs"; Path = $logsDir } +)) { + if (Test-Path -LiteralPath $entry.Path) { + if (Test-DirectoryWritable -Path $entry.Path) { + Write-Result OK "$($entry.Name) vorhanden und beschreibbar: $($entry.Path)" + } + else { + Write-Result FAIL "$($entry.Name) vorhanden, aber nicht beschreibbar: $($entry.Path)" + } + } + else { + Write-Result WARN "$($entry.Name) fehlt: $($entry.Path)" + } +} + +$pendingFiles = @() +$processingFiles = @() +$failedFiles = @() + +if (Test-Path -LiteralPath $queuePending) { + $pendingFiles = @(Get-ChildItem -LiteralPath $queuePending -Filter "*.eml" -File -ErrorAction SilentlyContinue) +} + +if (Test-Path -LiteralPath $queueProcessing) { + $processingFiles = @(Get-ChildItem -LiteralPath $queueProcessing -Filter "*.eml" -File -ErrorAction SilentlyContinue) +} + +if (Test-Path -LiteralPath $failedDir) { + $failedFiles = @(Get-ChildItem -LiteralPath $failedDir -Filter "*.eml" -File -ErrorAction SilentlyContinue) +} + +if ($pendingFiles.Count -eq 0) { + Write-Result OK "Pending Queue ist leer." +} +else { + $oldestPending = $pendingFiles | Sort-Object LastWriteTime | Select-Object -First 1 + $ageMinutes = [Math]::Floor(((Get-Date) - $oldestPending.LastWriteTime).TotalMinutes) + + if ($ageMinutes -ge $QueueWarningAgeMinutes) { + Write-Result WARN "$($pendingFiles.Count) Mail(s) in pending; älteste ist $ageMinutes Minuten alt." + } + else { + Write-Result INFO "$($pendingFiles.Count) Mail(s) in pending; älteste ist $ageMinutes Minuten alt." + } +} + +if ($processingFiles.Count -gt 0) { + Write-Result WARN "$($processingFiles.Count) Mail(s) liegen aktuell in processing." +} +else { + Write-Result OK "Processing Queue ist leer." +} + +if ($failedFiles.Count -ge $FailedWarningCount) { + Write-Result WARN "$($failedFiles.Count) Mail(s) liegen in failed." +} +else { + Write-Result OK "Failed Queue enthält $($failedFiles.Count) Mail(s)." +} + +# --------------------------------------------------------------------------- +# Scheduled Task +# --------------------------------------------------------------------------- +$task = Get-ScheduledTask -TaskName "SMTPGraphRelay" -ErrorAction SilentlyContinue + +if ($task) { + Write-Result OK "Scheduled Task 'SMTPGraphRelay' vorhanden." + + $taskInfo = Get-ScheduledTaskInfo -TaskName "SMTPGraphRelay" + + if ($task.State -eq "Running") { + Write-Result OK "Scheduled Task läuft." + } + else { + Write-Result WARN "Scheduled Task State: $($task.State)" + } + + if ($taskInfo.LastTaskResult -eq 0 -or $taskInfo.LastTaskResult -eq 267009) { + Write-Result OK "LastTaskResult: $($taskInfo.LastTaskResult)" + } + else { + Write-Result WARN "LastTaskResult: $($taskInfo.LastTaskResult)" + } + + $action = $task.Actions | Select-Object -First 1 + + if ($action.Execute -match 'WindowsPowerShell\\v1\.0\\powershell\.exe$') { + Write-Result OK "Scheduled Task verwendet Windows PowerShell 5.1." + } + else { + Write-Result WARN "Scheduled Task verwendet unerwartetes PowerShell-Binary: $($action.Execute)" + } +} +else { + Write-Result FAIL "Scheduled Task 'SMTPGraphRelay' wurde nicht gefunden." +} + +# --------------------------------------------------------------------------- +# SMTP Listener +# --------------------------------------------------------------------------- +$listenPort = [int]$config.Smtp.Port + +try { + $listeners = @(Get-NetTCPConnection -LocalPort $listenPort -State Listen -ErrorAction Stop) + + if ($listeners.Count -gt 0) { + $owners = @($listeners | Select-Object -ExpandProperty OwningProcess -Unique) + Write-Result OK "SMTP Listener aktiv auf TCP $listenPort (PID: $($owners -join ', '))." + } + else { + Write-Result FAIL "Kein Listener auf TCP $listenPort." + } +} +catch { + # Fallback falls Get-NetTCPConnection nicht verfügbar / eingeschränkt. + try { + $tcp = New-Object System.Net.Sockets.TcpClient + $result = $tcp.BeginConnect("127.0.0.1", $listenPort, $null, $null) + + if ($result.AsyncWaitHandle.WaitOne(2000)) { + $tcp.EndConnect($result) + Write-Result OK "SMTP Listener auf 127.0.0.1:$listenPort erreichbar." + } + else { + Write-Result FAIL "SMTP Listener auf 127.0.0.1:$listenPort nicht erreichbar." + } + + $tcp.Close() + } + catch { + Write-Result FAIL "SMTP Listener auf TCP $listenPort nicht erreichbar." + } +} + +# --------------------------------------------------------------------------- +# Graph App-only Auth +# --------------------------------------------------------------------------- +if ($graphModule -and $cert -and $cert.HasPrivateKey) { + try { + Import-Module Microsoft.Graph.Authentication -ErrorAction Stop + + Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null + + Connect-MgGraph ` + -TenantId $config.Graph.TenantId ` + -ClientId $config.Graph.ClientId ` + -Certificate $cert ` + -NoWelcome | Out-Null + + $ctx = Get-MgContext + + if ($ctx -and + $ctx.AuthType -eq "AppOnly" -and + $ctx.ClientId -eq $config.Graph.ClientId -and + $ctx.TenantId -eq $config.Graph.TenantId) { + + Write-Result OK "Microsoft Graph App-only Anmeldung erfolgreich." + } + else { + Write-Result FAIL "Graph-Verbindung vorhanden, aber Kontext entspricht nicht der Relay-App." + } + } + catch { + Write-Result FAIL "Microsoft Graph App-only Anmeldung fehlgeschlagen: $($_.Exception.Message)" + } + finally { + Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null + } +} +else { + Write-Result WARN "Graph App-only Test übersprungen, da Modul/Zertifikat/Private Key nicht vollständig verfügbar sind." +} + +# --------------------------------------------------------------------------- +# Optionale echte SMTP-Testmail +# --------------------------------------------------------------------------- +if ($SendTestMail) { + if ([string]::IsNullOrWhiteSpace($TestRecipient)) { + Write-Result FAIL "-SendTestMail wurde angegeben, aber -TestRecipient fehlt." + } + else { + try { + $testFrom = [string]$config.Graph.SenderMailbox + $smtpAuthPassword = $null + + if ($authRequired -and [string]::IsNullOrWhiteSpace($SmtpUsername)) { + $SmtpUsername = Read-Host "SMTP-Benutzer für Health-Check-Testmail" + } + + if (-not [string]::IsNullOrWhiteSpace($SmtpUsername)) { + $secureSmtpPassword = Read-Host "SMTP-Passwort für '$SmtpUsername'" -AsSecureString + $ptr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secureSmtpPassword) + + try { + $smtpAuthPassword = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($ptr) + } + finally { + [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ptr) + } + } + + Write-Result INFO "Sende SMTP-Testmail über 127.0.0.1:$listenPort an $TestRecipient ..." + + [void](Send-RawSmtpTestMail ` + -Server "127.0.0.1" ` + -Port $listenPort ` + -From $testFrom ` + -To $TestRecipient ` + -Username $SmtpUsername ` + -Password $smtpAuthPassword) + + Write-Result OK "SMTP-Testmail wurde vom Relay mit 250 Queued angenommen." + Write-Result INFO "Die endgültige Graph-/M365-Zustellung bitte im Relay-Log bzw. Empfängerpostfach prüfen." + } + catch { + Write-Result FAIL "SMTP-Testmail fehlgeschlagen: $($_.Exception.Message)" + } + finally { + $smtpAuthPassword = $null + } + } +} + +# --------------------------------------------------------------------------- +# Zusammenfassung +# --------------------------------------------------------------------------- +Write-Host "" +Write-Host "==========================================================" -ForegroundColor Cyan +Write-Host " Ergebnis" -ForegroundColor Cyan +Write-Host "==========================================================" -ForegroundColor Cyan +Write-Host "" + +Write-Host (" OK: {0}" -f $script:OkCount) -ForegroundColor Green +Write-Host (" WARN: {0}" -f $script:WarnCount) -ForegroundColor Yellow +Write-Host (" FAIL: {0}" -f $script:FailCount) -ForegroundColor Red +Write-Host "" + +if ($script:FailCount -gt 0) { + Write-Host "Gesamtstatus: FEHLER" -ForegroundColor Red + exit 2 +} +elseif ($script:WarnCount -gt 0) { + Write-Host "Gesamtstatus: WARNUNG" -ForegroundColor Yellow + exit 1 +} +else { + Write-Host "Gesamtstatus: OK" -ForegroundColor Green + exit 0 +}