#Requires -Version 5.1 #Requires -RunAsAdministrator [CmdletBinding()] param( [string]$InstallPath = "$env:ProgramFiles\SMTPGraphRelay" ) $ErrorActionPreference = "Stop" [Console]::OutputEncoding = [Text.Encoding]::UTF8 $TaskName = "SMTPGraphRelay" $ConfigPath = Join-Path $InstallPath "config.json" $ShutdownSignalPath = Join-Path $InstallPath "shutdown.request" 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 Get-Config { if (-not (Test-Path -LiteralPath $ConfigPath)) { throw "config.json nicht gefunden: $ConfigPath" } return Get-Content -LiteralPath $ConfigPath -Raw -Encoding UTF8 | ConvertFrom-Json } function Save-Config { param([Parameter(Mandatory)]$Config) $tmp = "$ConfigPath.qa.tmp" $Config | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $tmp -Encoding UTF8 Move-Item -LiteralPath $tmp -Destination $ConfigPath -Force } function Get-FullPath { param([string]$Value) if ([IO.Path]::IsPathRooted($Value)) { return $Value } return Join-Path $InstallPath $Value } function Stop-RelayGracefully { $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue if (-not $task -or $task.State -ne "Running") { return } [IO.File]::WriteAllText( $ShutdownSignalPath, [DateTime]::UtcNow.ToString("o"), (New-Object Text.UTF8Encoding($false)) ) $deadline = (Get-Date).AddSeconds(40) while ((Get-Date) -lt $deadline) { Start-Sleep -Milliseconds 250 $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue if (-not $task -or $task.State -ne "Running") { Remove-Item $ShutdownSignalPath -Force -ErrorAction SilentlyContinue return } } Write-Warn "Graceful Stop dauerte zu lange; Task wird hart gestoppt." Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue Remove-Item $ShutdownSignalPath -Force -ErrorAction SilentlyContinue Start-Sleep -Milliseconds 500 } function Start-Relay { Remove-Item $ShutdownSignalPath -Force -ErrorAction SilentlyContinue Start-ScheduledTask -TaskName $TaskName $deadline = (Get-Date).AddSeconds(10) while ((Get-Date) -lt $deadline) { Start-Sleep -Milliseconds 250 $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue if ($task -and $task.State -eq "Running") { return } } throw "Relay konnte nicht gestartet werden." } function Restart-Relay { Stop-RelayGracefully Start-Relay Start-Sleep -Seconds 1 } function Set-QADebug { param( [int]$DelayBeforeGraphSendSeconds = 0, [int]$SimulateGraphStatus = 0, [bool]$SimulateQueueWriteFailure = $false, [int]$OverridePendingCount = -1, [int]$OverrideFreeDiskSpaceMB = -1 ) $config = Get-Config $debug = [pscustomobject]@{ Enabled = $true DelayBeforeGraphSendSeconds = $DelayBeforeGraphSendSeconds SimulateGraphStatus = $SimulateGraphStatus SimulateQueueWriteFailure = $SimulateQueueWriteFailure OverridePendingCount = $OverridePendingCount OverrideFreeDiskSpaceMB = $OverrideFreeDiskSpaceMB } if ($config.PSObject.Properties.Name -contains "Debug") { $config.Debug = $debug } else { $config | Add-Member -NotePropertyName Debug -NotePropertyValue $debug } Save-Config -Config $config } function Restore-OriginalConfig { param([Parameter(Mandatory)][string]$BackupPath) Copy-Item -LiteralPath $BackupPath -Destination $ConfigPath -Force } function Convert-SecureToPlain { param([Security.SecureString]$Secure) $ptr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($Secure) try { return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($ptr) } finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ptr) } } function Get-SmtpAuthForLocalTest { $config = Get-Config $required = $false if ($config.Smtp.PSObject.Properties.Name -contains "RequireAuth") { $required = [bool]$config.Smtp.RequireAuth } if (-not $required) { return [pscustomobject]@{ Username=$null; Password=$null } } # Prüfen, ob localhost als Ausnahme eingetragen ist. foreach ($entry in @($config.Smtp.AllowUnauthenticatedNetworks)) { if ([string]$entry -in @("127.0.0.1","127.0.0.1/32","127.0.0.0/8","*")) { return [pscustomobject]@{ Username=$null; Password=$null } } } $user = Read-Host "SMTP-Benutzer für QA-Test" $secure = Read-Host "SMTP-Passwort" -AsSecureString $plain = Convert-SecureToPlain -Secure $secure return [pscustomobject]@{ Username=$user; Password=$plain } } function Read-SmtpResponse { param( [Parameter(Mandatory)][IO.StreamReader]$Reader ) $lines = New-Object Collections.Generic.List[string] $first = $Reader.ReadLine() if ($null -eq $first) { throw "SMTP-Verbindung unerwartet beendet." } $lines.Add($first) if ($first -match '^(\d{3})-') { $code = $matches[1] while ($true) { $line = $Reader.ReadLine() if ($null -eq $line) { throw "SMTP-Verbindung unerwartet beendet." } $lines.Add($line) if ($line -match "^$code ") { break } } } $status = 0 if ($lines[0] -match '^(\d{3})') { $status = [int]$matches[1] } return [pscustomobject]@{ Code = $status Text = ($lines -join "`n") } } function Invoke-AuthIfNeeded { param( [IO.StreamReader]$Reader, [IO.StreamWriter]$Writer, $Auth ) if ([string]::IsNullOrWhiteSpace([string]$Auth.Username)) { return } $Writer.WriteLine("AUTH LOGIN") $r = Read-SmtpResponse -Reader $Reader if ($r.Code -ne 334) { throw "AUTH LOGIN: $($r.Text)" } $Writer.WriteLine([Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($Auth.Username))) $r = Read-SmtpResponse -Reader $Reader if ($r.Code -ne 334) { throw "AUTH Benutzer: $($r.Text)" } $Writer.WriteLine([Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($Auth.Password))) $r = Read-SmtpResponse -Reader $Reader if ($r.Code -ne 235) { throw "AUTH Passwort: $($r.Text)" } } function Open-SmtpSession { $config = Get-Config $port = [int]$config.Smtp.Port $client = New-Object Net.Sockets.TcpClient $client.Connect("127.0.0.1", $port) $stream = $client.GetStream() $stream.ReadTimeout = 10000 $reader = New-Object IO.StreamReader($stream, [Text.Encoding]::UTF8, $true, 4096, $true) $writer = New-Object IO.StreamWriter($stream, [Text.Encoding]::ASCII, 4096, $true) $writer.NewLine = "`r`n" $writer.AutoFlush = $true $greeting = Read-SmtpResponse -Reader $reader if ($greeting.Code -ne 220) { throw "Greeting: $($greeting.Text)" } $writer.WriteLine("EHLO qa.local") $ehlo = Read-SmtpResponse -Reader $reader if ($ehlo.Code -ne 250) { throw "EHLO: $($ehlo.Text)" } $auth = Get-SmtpAuthForLocalTest Invoke-AuthIfNeeded -Reader $reader -Writer $writer -Auth $auth return [pscustomobject]@{ Client = $client Stream = $stream Reader = $reader Writer = $writer Config = $config Auth = $auth } } function Close-SmtpSession { param($Session) try { $Session.Writer.WriteLine("QUIT") } catch {} try { $Session.Reader.Dispose() } catch {} try { $Session.Writer.Dispose() } catch {} try { $Session.Stream.Dispose() } catch {} try { $Session.Client.Close() } catch {} if ($Session.Auth) { $Session.Auth.Password = $null } } function Send-TestMail { param( [switch]$StopBeforeBody, [string]$UniqueTag = $([guid]::NewGuid().ToString("N").Substring(0,10)) ) $s = Open-SmtpSession try { $from = [string]$s.Config.Graph.SenderMailbox $to = Read-Host "Test-Empfänger [Standard: $from]" if ([string]::IsNullOrWhiteSpace($to)) { $to = $from } $s.Writer.WriteLine("MAIL FROM:<$from>") $r = Read-SmtpResponse $s.Reader if ($r.Code -ne 250) { return [pscustomobject]@{ Session=$s; Response=$r; QueueId=$null; Stage="MAIL" } } $s.Writer.WriteLine("RCPT TO:<$to>") $r = Read-SmtpResponse $s.Reader if ($r.Code -ne 250) { return [pscustomobject]@{ Session=$s; Response=$r; QueueId=$null; Stage="RCPT" } } $s.Writer.WriteLine("DATA") $r = Read-SmtpResponse $s.Reader if ($r.Code -ne 354) { return [pscustomobject]@{ Session=$s; Response=$r; QueueId=$null; Stage="DATA" } } $s.Writer.WriteLine("From: <$from>") $s.Writer.WriteLine("To: <$to>") $s.Writer.WriteLine("Subject: SMTPGraphRelay QA $UniqueTag") $s.Writer.WriteLine("X-SMTPGraphRelay-QA: $UniqueTag") $s.Writer.WriteLine("") if ($StopBeforeBody) { return [pscustomobject]@{ Session=$s; Response=$r; QueueId=$null; Stage="DATA-OPEN"; Tag=$UniqueTag } } $s.Writer.WriteLine("QA test $UniqueTag") $s.Writer.WriteLine(".") $final = Read-SmtpResponse $s.Reader $qid = $null if ($final.Text -match 'queue-id=([A-Fa-f0-9]+)') { $qid = $matches[1] } return [pscustomobject]@{ Session=$s; Response=$final; QueueId=$qid; Stage="FINAL"; Tag=$UniqueTag } } catch { Close-SmtpSession $s throw } } function Get-QueuePaths { $config = Get-Config $queueRoot = Get-FullPath ([string]$config.Paths.Queue) return [pscustomobject]@{ Pending = Join-Path $queueRoot "pending" Processing = Join-Path $queueRoot "processing" Failed = Get-FullPath ([string]$config.Paths.Failed) } } function Remove-QAMessage { param([string]$QueueId) if ([string]::IsNullOrWhiteSpace($QueueId)) { return } $paths = Get-QueuePaths foreach ($dir in @($paths.Pending,$paths.Processing,$paths.Failed)) { foreach ($p in @( (Join-Path $dir "$QueueId.eml"), (Join-Path $dir "$QueueId.eml.json") )) { Remove-Item -LiteralPath $p -Force -ErrorAction SilentlyContinue } } } function Wait-ForQueueMeta { param( [string]$QueueId, [int]$TimeoutSeconds = 15 ) $paths = Get-QueuePaths $deadline = (Get-Date).AddSeconds($TimeoutSeconds) while ((Get-Date) -lt $deadline) { foreach ($dir in @($paths.Pending,$paths.Processing,$paths.Failed)) { $metaPath = Join-Path $dir "$QueueId.eml.json" if (Test-Path $metaPath) { try { $meta = Get-Content $metaPath -Raw -Encoding UTF8 | ConvertFrom-Json return [pscustomobject]@{ Directory=$dir; Meta=$meta; Path=$metaPath } } catch {} } } Start-Sleep -Milliseconds 250 } return $null } function Invoke-WithTemporaryConfig { param( [Parameter(Mandatory)][scriptblock]$Configure, [Parameter(Mandatory)][scriptblock]$Test ) $backup = Join-Path $env:TEMP ("SMTPGraphRelay-config-qa-{0}.json" -f [guid]::NewGuid().ToString("N")) Copy-Item $ConfigPath $backup -Force try { & $Configure Restart-Relay & $Test } finally { try { Restore-OriginalConfig -BackupPath $backup Restart-Relay Write-Ok "Original-config.json wiederhergestellt." } catch { Write-Fail "Config-Restore/Relay-Restart fehlgeschlagen: $($_.Exception.Message)" } Remove-Item $backup -Force -ErrorAction SilentlyContinue } } function Test-GraphStatus { param([int]$StatusCode) Invoke-WithTemporaryConfig ` -Configure { Set-QADebug -SimulateGraphStatus $StatusCode } ` -Test { $result = Send-TestMail try { if ($result.Response.Code -ne 250 -or -not $result.QueueId) { Write-Fail "SMTP-Annahme fehlgeschlagen: $($result.Response.Text)" return } Write-Ok "SMTP hat Testmail angenommen: $($result.QueueId)" $found = Wait-ForQueueMeta -QueueId $result.QueueId -TimeoutSeconds 15 if (-not $found) { Write-Fail "Queue-Metadaten nicht gefunden." return } Start-Sleep -Seconds 1 $found = Wait-ForQueueMeta -QueueId $result.QueueId -TimeoutSeconds 5 if ($found -and [int]$found.Meta.RetryCount -ge 1 -and [int]$found.Meta.LastStatusCode -eq $StatusCode) { Write-Ok "HTTP $StatusCode korrekt als Retry behandelt (RetryCount=$($found.Meta.RetryCount))." } else { Write-Fail "Erwartete Retry-Metadaten für HTTP $StatusCode fehlen." } } finally { if ($result) { Close-SmtpSession $result.Session Remove-QAMessage $result.QueueId } } } } function Test-QueueWriteFailure { Invoke-WithTemporaryConfig ` -Configure { Set-QADebug -SimulateQueueWriteFailure $true } ` -Test { $result = Send-TestMail try { if ($result.Stage -eq "FINAL" -and $result.Response.Code -eq 451) { Write-Ok "Queue-Schreibfehler korrekt mit 451 zurückgewiesen." } else { Write-Fail "Erwartet 451 nach DATA, erhalten: $($result.Response.Text)" } } finally { if ($result) { Close-SmtpSession $result.Session } } } } function Test-PendingBackpressure { $config = Get-Config $limit = if ($config.Queue.PSObject.Properties.Name -contains "MaxPendingMessages") { [int]$config.Queue.MaxPendingMessages } else { 5000 } Invoke-WithTemporaryConfig ` -Configure { Set-QADebug -OverridePendingCount $limit } ` -Test { $result = Send-TestMail try { if ($result.Stage -eq "DATA" -and $result.Response.Code -eq 452) { Write-Ok "Pending-Queue-Limit korrekt vor DATA mit 452 blockiert." } else { Write-Fail "Erwartet 452 bei DATA, erhalten: $($result.Response.Text)" } } finally { if ($result) { Close-SmtpSession $result.Session } } } } function Test-DiskBackpressure { Invoke-WithTemporaryConfig ` -Configure { Set-QADebug -OverrideFreeDiskSpaceMB 0 } ` -Test { $result = Send-TestMail try { if ($result.Stage -eq "DATA" -and $result.Response.Code -eq 452) { Write-Ok "Disk-Backpressure korrekt vor DATA mit 452 blockiert." } else { Write-Fail "Erwartet 452 bei DATA, erhalten: $($result.Response.Text)" } } finally { if ($result) { Close-SmtpSession $result.Session } } } } function Test-GracefulDuringData { Invoke-WithTemporaryConfig ` -Configure { Set-QADebug } ` -Test { $result = Send-TestMail -StopBeforeBody try { if ($result.Stage -ne "DATA-OPEN") { Write-Fail "DATA-Session konnte nicht geöffnet werden." return } Write-Info "DATA ist offen. Fordere jetzt Graceful Shutdown an..." [IO.File]::WriteAllText($ShutdownSignalPath, [DateTime]::UtcNow.ToString("o")) Start-Sleep -Seconds 1 $task = Get-ScheduledTask -TaskName $TaskName if ($task.State -eq "Running") { Write-Ok "Relay bleibt während aktiver DATA-Session erwartungsgemäß aktiv." } else { Write-Fail "Relay wurde beendet, obwohl DATA-Session noch offen war." return } $result.Session.Writer.WriteLine("QA graceful DATA test") $result.Session.Writer.WriteLine(".") $final = Read-SmtpResponse $result.Session.Reader if ($final.Code -eq 250) { Write-Ok "Laufende DATA-Mail wurde trotz Shutdown sauber angenommen." if ($final.Text -match 'queue-id=([A-Fa-f0-9]+)') { $result.QueueId = $matches[1] } } else { Write-Fail "Mailabschluss während Shutdown: $($final.Text)" } $deadline = (Get-Date).AddSeconds(15) do { Start-Sleep -Milliseconds 250 $task = Get-ScheduledTask -TaskName $TaskName } while ($task.State -eq "Running" -and (Get-Date) -lt $deadline) if ($task.State -ne "Running") { Write-Ok "Relay hat sich nach Abschluss der Session sauber beendet." } else { Write-Fail "Relay läuft nach Abschluss der Session weiter." } } finally { if ($result) { Close-SmtpSession $result.Session Remove-QAMessage $result.QueueId } Remove-Item $ShutdownSignalPath -Force -ErrorAction SilentlyContinue } } } function Test-GracefulDuringGraph { Invoke-WithTemporaryConfig ` -Configure { Set-QADebug -DelayBeforeGraphSendSeconds 8 -SimulateGraphStatus 500 } ` -Test { $result = Send-TestMail try { if ($result.Response.Code -ne 250 -or -not $result.QueueId) { Write-Fail "SMTP-Testmail wurde nicht angenommen." return } Close-SmtpSession $result.Session $result.Session = $null $paths = Get-QueuePaths $processing = Join-Path $paths.Processing "$($result.QueueId).eml" $deadline = (Get-Date).AddSeconds(10) while (-not (Test-Path $processing) -and (Get-Date) -lt $deadline) { Start-Sleep -Milliseconds 200 } if (-not (Test-Path $processing)) { Write-Fail "Mail erreichte processing nicht rechtzeitig." return } Write-Ok "Mail ist in processing / künstlicher Graph-Verzögerung." [IO.File]::WriteAllText($ShutdownSignalPath, [DateTime]::UtcNow.ToString("o")) Start-Sleep -Seconds 1 $task = Get-ScheduledTask -TaskName $TaskName if ($task.State -eq "Running") { Write-Ok "Relay wartet während aktivem Queue-/Graph-Worker." } else { Write-Fail "Relay wurde zu früh beendet." } $deadline = (Get-Date).AddSeconds(20) do { Start-Sleep -Milliseconds 250 $task = Get-ScheduledTask -TaskName $TaskName } while ($task.State -eq "Running" -and (Get-Date) -lt $deadline) if ($task.State -ne "Running") { Write-Ok "Relay hat Worker auslaufen lassen und sich danach beendet." } else { Write-Fail "Graceful Shutdown während Queue-/Graph-Worker dauerte zu lange." } } finally { if ($result -and $result.Session) { Close-SmtpSession $result.Session } if ($result) { Remove-QAMessage $result.QueueId } Remove-Item $ShutdownSignalPath -Force -ErrorAction SilentlyContinue } } } function Clear-QADebug { $config = Get-Config if ($config.PSObject.Properties.Name -contains "Debug") { $config.PSObject.Properties.Remove("Debug") Save-Config $config Restart-Relay Write-Ok "Debug-/QA-Block aus config.json entfernt." } else { Write-Ok "Kein Debug-/QA-Block vorhanden." } } if ($PSVersionTable.PSEdition -ne "Desktop" -or $PSVersionTable.PSVersion.Major -ne 5) { throw "Dieses QA-Script muss mit Windows PowerShell 5.1 ausgeführt werden." } while ($true) { Clear-Host Write-Title "SMTPGraphRelay - Failure Mode QA" Write-Warn "Dieses Werkzeug erzeugt absichtlich Fehlerzustände." Write-Host "Die Original-config.json wird für jeden Test gesichert und danach wiederhergestellt." Write-Host "" Write-Host " [1] Graph HTTP 429 / Retry testen" Write-Host " [2] Graph HTTP 500 / Retry testen" Write-Host " [3] Queue-Schreibfehler / SMTP 451 testen" Write-Host " [4] Pending-Queue-Backpressure / SMTP 452 testen" Write-Host " [5] Disk-Backpressure / SMTP 452 testen" Write-Host " [6] Graceful Shutdown während SMTP DATA testen" Write-Host " [7] Graceful Shutdown während Queue/Graph testen" Write-Host " [8] QA-/Debug-Block aus Config entfernen" Write-Host " [0] Beenden" Write-Host "" $choice = Read-Host "Auswahl" try { switch ($choice) { "1" { Test-GraphStatus -StatusCode 429 } "2" { Test-GraphStatus -StatusCode 500 } "3" { Test-QueueWriteFailure } "4" { Test-PendingBackpressure } "5" { Test-DiskBackpressure } "6" { Test-GracefulDuringData } "7" { Test-GracefulDuringGraph } "8" { Clear-QADebug } "0" { break } default { Write-Warn "Ungültige Auswahl." } } } catch { Write-Fail $_.Exception.Message } if ($choice -ne "0") { Write-Host "" Read-Host "Enter drücken" } if ($choice -eq "0") { break } }