Dateien nach "/" hochladen

This commit is contained in:
2026-08-14 22:58:17 +02:00
parent 9d49321abc
commit f70ee9e763
3 changed files with 801 additions and 6 deletions
+687
View File
@@ -0,0 +1,687 @@
#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 }
}