diff --git a/Test-SMTPGraphRelay.ps1 b/Test-SMTPGraphRelay.ps1 new file mode 100644 index 0000000..3c194c1 --- /dev/null +++ b/Test-SMTPGraphRelay.ps1 @@ -0,0 +1,589 @@ +#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, + + [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 + ) + + $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)) + + $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." +} + +# --------------------------------------------------------------------------- +# 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 + + 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) + + 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)" + } + } +} + +# --------------------------------------------------------------------------- +# 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 +}