From a1771adf0fdb866b340b51539568983ec9d412c9 Mon Sep 17 00:00:00 2001 From: "manuel.maier" Date: Fri, 14 Aug 2026 00:14:03 +0200 Subject: [PATCH] =?UTF-8?q?Test-SMTPGraphRelay.ps1=20gel=C3=B6scht?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Test-SMTPGraphRelay.ps1 | 589 ---------------------------------------- 1 file changed, 589 deletions(-) delete mode 100644 Test-SMTPGraphRelay.ps1 diff --git a/Test-SMTPGraphRelay.ps1 b/Test-SMTPGraphRelay.ps1 deleted file mode 100644 index 3c194c1..0000000 --- a/Test-SMTPGraphRelay.ps1 +++ /dev/null @@ -1,589 +0,0 @@ -#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 -}