#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 }