diff --git a/README.md b/README.md index a7206e7..019205f 100644 --- a/README.md +++ b/README.md @@ -1,104 +1,137 @@ -# SMTPGraphRelay V1.4 – Zertifikats-Lifecycle +# SMTPGraphRelay V1.5 – Log-Rotation -V1.4 erweitert V1.3 um Zertifikatsüberwachung und sichere Rotation. +V1.5 erweitert V1.4 um automatische Log-Rotation und Log-Aufbewahrung. -## Relay: automatische Zertifikatsüberwachung +## Standardverhalten -Beim Start wird das Zertifikat aus `Graph.CertificateThumbprint` geprüft: +Ohne Änderungen an `config.json` gelten: -- Zertifikat vorhanden -- privater Schlüssel vorhanden -- Ablaufdatum +```text +MaxFileSizeMB = 10 +RetentionDays = 30 +CleanupHours = 12 +``` -Standardwerte: +Das aktive Log bleibt: -- Warnung ab 60 Tagen Restlaufzeit -- kritisch ab 14 Tagen -- erneute Prüfung alle 12 Stunden +```text +logs\SMTPGraphRelay.log +``` -Optional in `config.json`: +Sobald es 10 MB erreicht, wird es z. B. umbenannt in: + +```text +logs\SMTPGraphRelay-20260813-231500.log +``` + +Danach wird automatisch eine neue `SMTPGraphRelay.log` begonnen. + +Archiv-Logs, die älter als 30 Tage sind, werden automatisch entfernt. + +## Optionale Konfiguration + +Die bestehende Config funktioniert unverändert. + +Optional kann auf Root-Ebene ergänzt werden: ```json -"Graph": { - "TenantId": "...", - "ClientId": "...", - "CertificateThumbprint": "...", - "SenderMailbox": "info@example.com", - "ForceSender": true, - "CertificateWarningDays": 60, - "CertificateCriticalDays": 14, - "CertificateCheckHours": 12 +"Logging": { + "MaxFileSizeMB": 10, + "RetentionDays": 30, + "CleanupHours": 12 } ``` -Die zusätzlichen Werte sind optional; bestehende Configs funktionieren unverändert. +Beispiel: -## Rotation - -`Renew-SMTPGraphRelayCertificate.ps1` muss als Administrator mit -**Windows PowerShell 5.1** ausgeführt werden. - -```powershell -cd "C:\Program Files\SMTPGraphRelay" -.\Renew-SMTPGraphRelayCertificate.ps1 +```json +{ + "Smtp": { + "ListenAddress": "0.0.0.0", + "Port": 2525, + "Hostname": "SMTPRELAY01", + "AllowedNetworks": [ + "127.0.0.1/32", + "10.0.0.0/8" + ], + "MaxMessageSizeMB": 25, + "ClientTimeoutSeconds": 120, + "MaxConcurrentClients": 20 + }, + "Graph": { + "TenantId": "...", + "ClientId": "...", + "CertificateThumbprint": "...", + "SenderMailbox": "info@example.com", + "ForceSender": true + }, + "Queue": { + "PollSeconds": 10, + "MaxRetries": 8, + "RetryMinutes": [1,5,15,30,60,120,240,480] + }, + "Paths": { + "Queue": "queue", + "Failed": "failed", + "Logs": "logs" + }, + "Logging": { + "MaxFileSizeMB": 10, + "RetentionDays": 30, + "CleanupHours": 12 + } +} ``` -Ablauf: +## Parallel-sicher -1. bestehende Config lesen -2. neues nicht exportierbares Zertifikat in `LocalMachine\My` erzeugen -3. Entra-Admin interaktiv anmelden -4. bestehende App über ihre ClientId finden -5. neues Zertifikat **zusätzlich** in `keyCredentials` hinterlegen -6. App-only Login mit dem neuen Zertifikat testen -7. erst danach `config.json` atomar auf den neuen Thumbprint umstellen -8. Relay-Scheduled-Task neu starten -9. optional altes Cloud- und lokales Zertifikat entfernen - -Exchange Application RBAC muss nicht neu aufgebaut werden, weil die App/ClientId -bei der Rotation unverändert bleibt. - -## Warum das alte Zertifikat zunächst erhalten bleibt - -Die sichere Reihenfolge ist: +V1.5 verwendet weiterhin den benannten Mutex: ```text -OLD funktioniert - | -NEW erzeugen - | -OLD + NEW in Entra - | -NEW Auth testen - | -Config -> NEW - | -Relay neu starten - | -erst dann optional OLD entfernen +Local\SMTPGraphRelay-Log ``` -So gibt es während der Rotation keinen Zeitpunkt ohne gültiges Credential und ein -Rollback bleibt möglich. +Rotation und Schreiben passieren innerhalb desselben Locks. Dadurch können parallele +SMTP-Runspaces und der Queue-/Graph-Worker nicht gleichzeitig dieselbe Logdatei +rotieren oder beschreiben. -## Update von V1.3 - -Task stoppen: +## Update von V1.4 ```powershell Stop-ScheduledTask -TaskName "SMTPGraphRelay" + +Copy-Item ` + "C:\Program Files\SMTPGraphRelay\SMTPGraphRelay.ps1" ` + "C:\Program Files\SMTPGraphRelay\SMTPGraphRelay-V1.4-backup.ps1" ``` -V1.3 sichern und V1.4 als `SMTPGraphRelay.ps1` ablegen, dann: +Dann V1.5 als: + +```text +C:\Program Files\SMTPGraphRelay\SMTPGraphRelay.ps1 +``` + +ablegen und: ```powershell Start-ScheduledTask -TaskName "SMTPGraphRelay" ``` -Die `config.json` muss nicht geändert werden. - -Im Log erscheint bei einem gesunden Zertifikat beim Start beispielsweise: +Im Log sollte beim Start u. a. erscheinen: ```text -Graph-Zertifikat gültig bis 2028-08-13 23:00:00 (729 Tage verbleibend). +SMTPGraphRelay V1.5 gestartet ... +Log-Rotation: max. 10 MB pro Datei, Aufbewahrung 30 Tage. ``` + +## Hinweis + +Nur archivierte Dateien im Muster + +```text +SMTPGraphRelay-*.log +``` + +werden automatisch gelöscht. + +Die aktuell aktive `SMTPGraphRelay.log` wird nie durch die Retention-Regel gelöscht. diff --git a/SMTPGraphRelay.ps1 b/SMTPGraphRelay.ps1 index aae3147..fb17a5a 100644 --- a/SMTPGraphRelay.ps1 +++ b/SMTPGraphRelay.ps1 @@ -7,7 +7,7 @@ Nimmt lokale SMTP-Mails an, speichert sie als .eml in einer Queue und sendet sie anschließend per Microsoft Graph sendMail mit App-only Zertifikatsauthentifizierung. - V1.4: Zertifikatsüberwachung, parallele SMTP-Clients, separater Queue-Worker, robuste Queue und Graph-Retry + V1.5: Log-Rotation, Zertifikatsüberwachung, parallele SMTP-Clients, Queue-Worker und robuste Graph-Retry-Logik #> [CmdletBinding()] @@ -18,6 +18,114 @@ param( $ErrorActionPreference = "Stop" [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 +function Get-LogSettings { + $maxSizeMB = 10 + $retentionDays = 30 + $cleanupHours = 12 + + try { + if ($script:Config.Logging) { + if ($script:Config.Logging.PSObject.Properties.Name -contains "MaxFileSizeMB") { + $v = [int]$script:Config.Logging.MaxFileSizeMB + if ($v -ge 1 -and $v -le 1024) { $maxSizeMB = $v } + } + + if ($script:Config.Logging.PSObject.Properties.Name -contains "RetentionDays") { + $v = [int]$script:Config.Logging.RetentionDays + if ($v -ge 1 -and $v -le 3650) { $retentionDays = $v } + } + + if ($script:Config.Logging.PSObject.Properties.Name -contains "CleanupHours") { + $v = [int]$script:Config.Logging.CleanupHours + if ($v -ge 1 -and $v -le 168) { $cleanupHours = $v } + } + } + } catch {} + + return [pscustomobject]@{ + MaxFileSizeMB = $maxSizeMB + RetentionDays = $retentionDays + CleanupHours = $cleanupHours + } +} + +function Invoke-LogMaintenance { + param( + [switch]$Force + ) + + if (-not $script:Config -or -not $script:Config.Paths.Logs) { + return + } + + $settings = Get-LogSettings + $logDir = $script:Config.Paths.Logs + + if (-not [IO.Path]::IsPathRooted($logDir)) { + if ($PSScriptRoot) { + $logDir = Join-Path $PSScriptRoot $logDir + } else { + return + } + } + + New-Item -ItemType Directory -Path $logDir -Force | Out-Null + + if (-not $script:NextLogCleanup) { + $script:NextLogCleanup = [DateTime]::MinValue + } + + if (-not $Force -and (Get-Date) -lt $script:NextLogCleanup) { + return + } + + $cutoff = (Get-Date).AddDays(-1 * $settings.RetentionDays) + + foreach ($file in Get-ChildItem -LiteralPath $logDir -File -Filter "SMTPGraphRelay-*.log" -ErrorAction SilentlyContinue) { + if ($file.LastWriteTime -lt $cutoff) { + try { + Remove-Item -LiteralPath $file.FullName -Force -ErrorAction Stop + } catch {} + } + } + + $script:NextLogCleanup = (Get-Date).AddHours($settings.CleanupHours) +} + +function Rotate-LogIfNeeded { + param( + [Parameter(Mandatory)][string]$LogFile + ) + + if (-not (Test-Path -LiteralPath $LogFile)) { + return + } + + $settings = Get-LogSettings + + try { + $file = Get-Item -LiteralPath $LogFile -ErrorAction Stop + $maxBytes = [int64]$settings.MaxFileSizeMB * 1024 * 1024 + + if ($file.Length -lt $maxBytes) { + return + } + + $stamp = Get-Date -Format "yyyyMMdd-HHmmss" + $dir = Split-Path $LogFile -Parent + $archive = Join-Path $dir "SMTPGraphRelay-$stamp.log" + + $counter = 1 + while (Test-Path -LiteralPath $archive) { + $archive = Join-Path $dir ("SMTPGraphRelay-{0}-{1}.log" -f $stamp, $counter) + $counter++ + } + + Move-Item -LiteralPath $LogFile -Destination $archive -Force + } + catch {} +} + function Write-Log { param( [Parameter(Mandatory)][string]$Message, @@ -31,28 +139,42 @@ function Write-Log { try { if ($script:Config -and $script:Config.Paths.Logs) { $logDir = $script:Config.Paths.Logs - if (-not [IO.Path]::IsPathRooted($logDir)) { $logDir = Join-Path $PSScriptRoot $logDir } + + if (-not [IO.Path]::IsPathRooted($logDir)) { + if ($PSScriptRoot) { + $logDir = Join-Path $PSScriptRoot $logDir + } else { + return + } + } + New-Item -ItemType Directory -Path $logDir -Force | Out-Null $logFile = Join-Path $logDir "SMTPGraphRelay.log" # Mehrere SMTP-Runspaces und der Queue-Worker können gleichzeitig loggen. - # Ein benannter Mutex verhindert kollidierende Schreibzugriffe. + # Ein benannter Mutex schützt Rotation und Schreibzugriff gemeinsam. $mutex = New-Object System.Threading.Mutex($false, "Local\SMTPGraphRelay-Log") $lockTaken = $false + try { $lockTaken = $mutex.WaitOne(5000) + if ($lockTaken) { + Rotate-LogIfNeeded -LogFile $logFile Add-Content -LiteralPath $logFile -Value $line -Encoding UTF8 + Invoke-LogMaintenance } } finally { if ($lockTaken) { try { $mutex.ReleaseMutex() } catch {} } + $mutex.Dispose() } } - } catch {} + } + catch {} } function Resolve-PathFromConfig { @@ -999,6 +1121,9 @@ $script:Config = Get-Content -LiteralPath $ConfigPath -Raw -Encoding UTF8 | Conv New-Item -ItemType Directory -Path (Resolve-PathFromConfig $script:Config.Paths.Logs) -Force | Out-Null Initialize-QueueDirectories +# Alte Logarchive beim Start direkt bereinigen. +Invoke-LogMaintenance -Force + # Zertifikat beim Start immer prüfen und Status protokollieren. [void](Test-RelayCertificateExpiry -ForceLog) @@ -1039,6 +1164,9 @@ $script:WorkerConfig = New-WorkerConfig # Nur die Funktionen, die ein SMTP-Client wirklich benötigt, werden in die # Worker-Runspaces kopiert. Graph-/Queue-Versand bleibt in einem separaten Worker. $smtpFunctionNames = @( + "Get-LogSettings", + "Invoke-LogMaintenance", + "Rotate-LogIfNeeded", "Write-Log", "Resolve-PathFromConfig", "Test-IPv4InCidr", @@ -1072,6 +1200,9 @@ $script:ActiveSmtpWorkers = New-Object System.Collections.ArrayList # Damit ein langsamer Graph-Aufruf nicht mehr die Annahme neuer SMTP-Verbindungen # blockiert, läuft Process-Queue dauerhaft in einem eigenen Runspace. $queueFunctionNames = @( + "Get-LogSettings", + "Invoke-LogMaintenance", + "Rotate-LogIfNeeded", "Write-Log", "Resolve-PathFromConfig", "Get-GraphConnection", @@ -1120,11 +1251,15 @@ $listenIp = [System.Net.IPAddress]::Parse($script:Config.Smtp.ListenAddress) $listener = [System.Net.Sockets.TcpListener]::new($listenIp, [int]$script:Config.Smtp.Port) $listener.Start() -Write-Log "SMTPGraphRelay V1.3 gestartet auf $($script:Config.Smtp.ListenAddress):$($script:Config.Smtp.Port)" +Write-Log "SMTPGraphRelay V1.5 gestartet auf $($script:Config.Smtp.ListenAddress):$($script:Config.Smtp.Port)" Write-Log "Graph-Absender: $($script:Config.Graph.SenderMailbox)" Write-Log "Maximale parallele SMTP-Verbindungen: $script:MaxConcurrentClients" Write-Log "Queue-/Graph-Worker läuft separat vom SMTP-Listener." +$logSettings = Get-LogSettings +Write-Log ("Log-Rotation: max. {0} MB pro Datei, Aufbewahrung {1} Tage." -f ` + $logSettings.MaxFileSizeMB, $logSettings.RetentionDays) + try { while ($true) { Remove-CompletedSmtpWorkers