diff --git a/Renew-SMTPGraphRelayCertificate.ps1 b/Renew-SMTPGraphRelayCertificate.ps1 new file mode 100644 index 0000000..c4fd3ea --- /dev/null +++ b/Renew-SMTPGraphRelayCertificate.ps1 @@ -0,0 +1,338 @@ +#Requires -Version 5.1 +#Requires -RunAsAdministrator +<# +.SYNOPSIS + Sichere Zertifikatsrotation für SMTPGraphRelay. + +.DESCRIPTION + - Muss mit Windows PowerShell 5.1 ausgeführt werden. + - Liest TenantId, ClientId und aktuellen Thumbprint aus config.json. + - Erzeugt ein neues nicht exportierbares RSA-Zertifikat in LocalMachine\My. + - Meldet einen Entra-Administrator interaktiv an. + - Fügt das neue öffentliche Zertifikat zusätzlich zur bestehenden App Registration hinzu. + - Testet App-only Authentication mit dem neuen Zertifikat. + - Aktualisiert erst danach config.json. + - Startet den SMTPGraphRelay Scheduled Task neu. + - Entfernt das alte App-Zertifikat / lokale Zertifikat nur auf Wunsch. + + Exchange Application RBAC bleibt unverändert, da ClientId/App Registration gleich bleibt. +#> + +[CmdletBinding()] +param( + [string]$ConfigPath = "$PSScriptRoot\config.json", + [int]$ValidityYears = 2 +) + +$ErrorActionPreference = "Stop" +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + +if ($PSVersionTable.PSEdition -ne "Desktop" -or $PSVersionTable.PSVersion.Major -ne 5) { + Write-Host "" + Write-Host "Dieses Skript muss mit Windows PowerShell 5.1 ausgeführt werden." -ForegroundColor Red + Write-Host "Bitte starten:" -ForegroundColor Yellow + Write-Host " C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe" + Write-Host "" + Read-Host "Enter drücken zum Beenden" + exit 1 +} + +if (-not (Test-Path -LiteralPath $ConfigPath)) { + throw "config.json nicht gefunden: $ConfigPath" +} + +if ($ValidityYears -lt 1 -or $ValidityYears -gt 5) { + throw "ValidityYears muss zwischen 1 und 5 liegen." +} + +$config = Get-Content -LiteralPath $ConfigPath -Raw -Encoding UTF8 | ConvertFrom-Json + +$TenantId = [string]$config.Graph.TenantId +$ClientId = [string]$config.Graph.ClientId +$OldThumbprint = [string]$config.Graph.CertificateThumbprint + +if ([string]::IsNullOrWhiteSpace($TenantId) -or + [string]::IsNullOrWhiteSpace($ClientId) -or + [string]::IsNullOrWhiteSpace($OldThumbprint)) { + throw "TenantId, ClientId oder CertificateThumbprint fehlen in config.json." +} + +Write-Host "==========================================================" -ForegroundColor Cyan +Write-Host " SMTPGraphRelay - Zertifikatsrotation" -ForegroundColor Cyan +Write-Host "==========================================================" -ForegroundColor Cyan +Write-Host "" +Write-Host "Tenant ID: $TenantId" +Write-Host "Client ID: $ClientId" +Write-Host "Alter Thumbprint:$OldThumbprint" +Write-Host "" + +$modules = @( + "Microsoft.Graph.Authentication", + "Microsoft.Graph.Applications" +) + +foreach ($module in $modules) { + if (-not (Get-Module -ListAvailable -Name $module)) { + Write-Host "-> $module fehlt. Wird für alle Benutzer installiert..." -ForegroundColor Yellow + Install-Module $module -Scope AllUsers -Repository PSGallery -Force -AllowClobber + } +} + +Import-Module Microsoft.Graph.Authentication -Force -ErrorAction Stop +Import-Module Microsoft.Graph.Applications -Force -ErrorAction Stop + +$oldCert = Get-Item -LiteralPath "Cert:\LocalMachine\My\$OldThumbprint" -ErrorAction Stop +Write-Host "Altes Zertifikat gültig bis: $($oldCert.NotAfter)" -ForegroundColor DarkGray +Write-Host "" + +Write-Host "Schritt 1: Neues Zertifikat erzeugen..." -ForegroundColor Cyan + +$certSubject = "CN=SMTPGraphRelay-$env:COMPUTERNAME" +$newCert = New-SelfSignedCertificate ` + -Subject $certSubject ` + -CertStoreLocation "Cert:\LocalMachine\My" ` + -KeyAlgorithm RSA ` + -KeyLength 2048 ` + -HashAlgorithm SHA256 ` + -KeyExportPolicy NonExportable ` + -KeySpec Signature ` + -NotAfter (Get-Date).AddYears($ValidityYears) + +Write-Host "-> Neues Zertifikat: $($newCert.Thumbprint)" -ForegroundColor Green +Write-Host "-> Gültig bis: $($newCert.NotAfter)" -ForegroundColor Green +Write-Host "" + +try { + Write-Host "Schritt 2: Entra-Administrator anmelden..." -ForegroundColor Cyan + Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null + Connect-MgGraph ` + -TenantId $TenantId ` + -Scopes "Application.ReadWrite.All" ` + -NoWelcome + + $ctx = Get-MgContext + if (-not $ctx -or $ctx.TenantId -ne $TenantId) { + throw "Graph-Anmeldung am erwarteten Tenant fehlgeschlagen." + } + + Write-Host "-> Angemeldet an Tenant $($ctx.TenantId)." -ForegroundColor Green + Write-Host "" + + Write-Host "Schritt 3: Bestehende App Registration laden..." -ForegroundColor Cyan + $app = Get-MgApplication -Filter "appId eq '$ClientId'" -Property "id,appId,displayName,keyCredentials" | Select-Object -First 1 + + if (-not $app) { + throw "App Registration mit ClientId '$ClientId' wurde nicht gefunden." + } + + Write-Host "-> App gefunden: $($app.DisplayName)" -ForegroundColor Green + + $alreadyPresent = $false + foreach ($key in @($app.KeyCredentials)) { + if ($key.CustomKeyIdentifier) { + $keyThumb = ([BitConverter]::ToString($key.CustomKeyIdentifier)).Replace("-","") + if ($keyThumb -eq $newCert.Thumbprint) { + $alreadyPresent = $true + break + } + } + } + + if (-not $alreadyPresent) { + Write-Host "Schritt 4: Neues Zertifikat zusätzlich zur App hinzufügen..." -ForegroundColor Cyan + + $existingKeys = @() + foreach ($key in @($app.KeyCredentials)) { + $existingKeys += @{ + CustomKeyIdentifier = $key.CustomKeyIdentifier + DisplayName = $key.DisplayName + EndDateTime = $key.EndDateTime + KeyId = $key.KeyId + StartDateTime = $key.StartDateTime + Type = $key.Type + Usage = $key.Usage + } + } + + $newKey = @{ + Type = "AsymmetricX509Cert" + Usage = "Verify" + Key = $newCert.GetRawCertData() + DisplayName = "SMTPGraphRelay Certificate $($newCert.NotAfter.ToString('yyyy-MM-dd'))" + StartDateTime = $newCert.NotBefore.ToUniversalTime() + EndDateTime = $newCert.NotAfter.ToUniversalTime() + } + + $allKeys = @($existingKeys) + @($newKey) + + Update-MgApplication ` + -ApplicationId $app.Id ` + -KeyCredentials $allKeys + + Write-Host "-> Neues Zertifikat wurde zusätzlich registriert." -ForegroundColor Green + } + else { + Write-Host "-> Neues Zertifikat ist bereits in der App registriert." -ForegroundColor Yellow + } + + Write-Host "" + Write-Host "Schritt 5: App-only Anmeldung mit NEUEM Zertifikat testen..." -ForegroundColor Cyan + + Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null + + $authSucceeded = $false + $lastAuthError = $null + + # Entra kann kurz brauchen, bis das neue Credential überall verfügbar ist. + for ($attempt = 1; $attempt -le 6; $attempt++) { + try { + Connect-MgGraph ` + -TenantId $TenantId ` + -ClientId $ClientId ` + -Certificate $newCert ` + -NoWelcome | Out-Null + + $appCtx = Get-MgContext + if ($appCtx -and $appCtx.AuthType -eq "AppOnly" -and $appCtx.ClientId -eq $ClientId) { + $authSucceeded = $true + break + } + + throw "Graph-Kontext ist nicht AppOnly oder hat eine unerwartete ClientId." + } + catch { + $lastAuthError = $_.Exception.Message + Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null + + if ($attempt -lt 6) { + Write-Host "-> Noch nicht verfügbar (Versuch $attempt/6), neuer Versuch..." -ForegroundColor Yellow + Start-Sleep -Seconds 10 + } + } + } + + if (-not $authSucceeded) { + throw "Neues Zertifikat konnte nicht zur App-only Anmeldung verwendet werden: $lastAuthError" + } + + Write-Host "-> App-only Anmeldung mit neuem Zertifikat erfolgreich." -ForegroundColor Green + Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null + Write-Host "" + + Write-Host "Schritt 6: config.json atomar auf neuen Thumbprint umstellen..." -ForegroundColor Cyan + + $backupPath = "$ConfigPath.bak-$(Get-Date -Format 'yyyyMMdd-HHmmss')" + Copy-Item -LiteralPath $ConfigPath -Destination $backupPath -Force + + $config.Graph.CertificateThumbprint = $newCert.Thumbprint + $tmpConfig = "$ConfigPath.tmp" + $config | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $tmpConfig -Encoding UTF8 + Move-Item -LiteralPath $tmpConfig -Destination $ConfigPath -Force + + Write-Host "-> config.json aktualisiert." -ForegroundColor Green + Write-Host "-> Backup: $backupPath" -ForegroundColor DarkGray + Write-Host "" + + Write-Host "Schritt 7: SMTPGraphRelay neu starten..." -ForegroundColor Cyan + $task = Get-ScheduledTask -TaskName "SMTPGraphRelay" -ErrorAction SilentlyContinue + + if ($task) { + Stop-ScheduledTask -TaskName "SMTPGraphRelay" -ErrorAction SilentlyContinue + Start-Sleep -Seconds 1 + Start-ScheduledTask -TaskName "SMTPGraphRelay" + Start-Sleep -Seconds 2 + Write-Host "-> Scheduled Task neu gestartet." -ForegroundColor Green + } + else { + Write-Host "-> Scheduled Task 'SMTPGraphRelay' nicht gefunden. Bitte Relay manuell neu starten." -ForegroundColor Yellow + } + + Write-Host "" + Write-Host "==========================================================" -ForegroundColor Green + Write-Host " ZERTIFIKATSROTATION ERFOLGREICH" -ForegroundColor Green + Write-Host "==========================================================" -ForegroundColor Green + Write-Host "" + Write-Host "Alter Thumbprint: $OldThumbprint" + Write-Host "Neuer Thumbprint: $($newCert.Thumbprint)" -ForegroundColor Yellow + Write-Host "" + + Write-Host "Sicherheitsreserve:" -ForegroundColor Cyan + Write-Host "Das alte Zertifikat bleibt zunächst parallel registriert." + Write-Host "Damit ist ein Rollback über das config.json-Backup möglich." + Write-Host "" + + $removeOld = Read-Host "Altes Zertifikat jetzt aus Entra UND LocalMachine entfernen? [j/N]" + if ($removeOld -match '^(?i)j|ja|y|yes$') { + Write-Host "" + Write-Host "Schritt 8: Altes Zertifikat entfernen..." -ForegroundColor Cyan + + # Für die Änderung wieder Admin-Kontext herstellen. + Connect-MgGraph ` + -TenantId $TenantId ` + -Scopes "Application.ReadWrite.All" ` + -NoWelcome + + $appFresh = Get-MgApplication -ApplicationId $app.Id -Property "id,keyCredentials" + + $remainingKeys = @() + $oldFound = $false + + foreach ($key in @($appFresh.KeyCredentials)) { + $isOld = $false + + if ($key.CustomKeyIdentifier) { + $keyThumb = ([BitConverter]::ToString($key.CustomKeyIdentifier)).Replace("-","") + if ($keyThumb -eq $OldThumbprint) { + $isOld = $true + $oldFound = $true + } + } + + if (-not $isOld) { + $remainingKeys += @{ + CustomKeyIdentifier = $key.CustomKeyIdentifier + DisplayName = $key.DisplayName + EndDateTime = $key.EndDateTime + KeyId = $key.KeyId + StartDateTime = $key.StartDateTime + Type = $key.Type + Usage = $key.Usage + } + } + } + + if ($oldFound) { + Update-MgApplication ` + -ApplicationId $app.Id ` + -KeyCredentials $remainingKeys + + Write-Host "-> Altes Zertifikat aus Entra entfernt." -ForegroundColor Green + } + else { + Write-Host "-> Alter Thumbprint war in Entra nicht mehr vorhanden." -ForegroundColor Yellow + } + + Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null + + Remove-Item -LiteralPath "Cert:\LocalMachine\My\$OldThumbprint" -Force -ErrorAction Stop + Write-Host "-> Altes lokales Zertifikat entfernt." -ForegroundColor Green + } + else { + Write-Host "-> Altes Zertifikat bleibt als Rollback-Reserve bestehen." -ForegroundColor Yellow + } +} +catch { + Write-Host "" + Write-Host "FEHLER BEI DER ZERTIFIKATSROTATION:" -ForegroundColor Red + Write-Host $_.Exception.Message -ForegroundColor Red + Write-Host "" + Write-Host "Die bestehende config.json wurde nur nach erfolgreichem Auth-Test geändert." -ForegroundColor Yellow + Write-Host "Falls das neue Zertifikat bereits erzeugt/hochgeladen wurde, kann es später manuell bereinigt werden." -ForegroundColor Yellow + throw +} +finally { + Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null +} + +Write-Host "" +Read-Host "Enter drücken zum Beenden" diff --git a/SMTPGraphRelay.ps1 b/SMTPGraphRelay.ps1 index ba62ee0..aae3147 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.3: parallele SMTP-Clients, separater Queue-Worker, robuste Queue, statuscodeabhängiger Graph-Retry + V1.4: Zertifikatsüberwachung, parallele SMTP-Clients, separater Queue-Worker, robuste Queue und Graph-Retry #> [CmdletBinding()] @@ -803,6 +803,80 @@ function Handle-SmtpClient { } +function Get-RelayCertificateStatus { + $thumbprint = [string]$script:Config.Graph.CertificateThumbprint + + if ([string]::IsNullOrWhiteSpace($thumbprint)) { + throw "Graph.CertificateThumbprint fehlt in config.json." + } + + $certPath = "Cert:\LocalMachine\My\$thumbprint" + $cert = Get-Item -LiteralPath $certPath -ErrorAction Stop + + if (-not $cert.HasPrivateKey) { + throw "Relay-Zertifikat '$thumbprint' besitzt keinen privaten Schlüssel." + } + + $remaining = $cert.NotAfter.ToUniversalTime() - [DateTime]::UtcNow + + return [pscustomobject]@{ + Certificate = $cert + Thumbprint = $cert.Thumbprint + Subject = $cert.Subject + NotBefore = $cert.NotBefore + NotAfter = $cert.NotAfter + DaysRemaining = [Math]::Floor($remaining.TotalDays) + HoursRemaining = [Math]::Floor($remaining.TotalHours) + Expired = ($remaining.TotalSeconds -le 0) + } +} + +function Test-RelayCertificateExpiry { + param( + [switch]$ForceLog + ) + + try { + $status = Get-RelayCertificateStatus + + $warningDays = 60 + $criticalDays = 14 + + if ($script:Config.Graph.PSObject.Properties.Name -contains "CertificateWarningDays") { + try { $warningDays = [int]$script:Config.Graph.CertificateWarningDays } catch {} + } + + if ($script:Config.Graph.PSObject.Properties.Name -contains "CertificateCriticalDays") { + try { $criticalDays = [int]$script:Config.Graph.CertificateCriticalDays } catch {} + } + + if ($status.Expired) { + Write-Log ("KRITISCH: Graph-Zertifikat {0} ist seit {1} abgelaufen!" -f ` + $status.Thumbprint, $status.NotAfter.ToString("yyyy-MM-dd HH:mm:ss")) "ERROR" + return $status + } + + if ($status.DaysRemaining -le $criticalDays) { + Write-Log ("KRITISCH: Graph-Zertifikat läuft in {0} Tagen ab ({1}). Bitte Zertifikat erneuern." -f ` + $status.DaysRemaining, $status.NotAfter.ToString("yyyy-MM-dd HH:mm:ss")) "ERROR" + } + elseif ($status.DaysRemaining -le $warningDays) { + Write-Log ("WARNUNG: Graph-Zertifikat läuft in {0} Tagen ab ({1}). Zertifikatsrotation einplanen." -f ` + $status.DaysRemaining, $status.NotAfter.ToString("yyyy-MM-dd HH:mm:ss")) "WARN" + } + elseif ($ForceLog) { + Write-Log ("Graph-Zertifikat gültig bis {0} ({1} Tage verbleibend)." -f ` + $status.NotAfter.ToString("yyyy-MM-dd HH:mm:ss"), $status.DaysRemaining) + } + + return $status + } + catch { + Write-Log ("Zertifikatsprüfung fehlgeschlagen: {0}" -f $_.Exception.Message) "ERROR" + return $null + } +} + function Get-FunctionBootstrap { param( [Parameter(Mandatory)] @@ -925,6 +999,21 @@ $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 +# Zertifikat beim Start immer prüfen und Status protokollieren. +[void](Test-RelayCertificateExpiry -ForceLog) + +# Prüfintervall optional per config, Standard 12 Stunden. +$script:CertificateCheckHours = 12 +if ($script:Config.Graph.PSObject.Properties.Name -contains "CertificateCheckHours") { + try { + $configuredHours = [int]$script:Config.Graph.CertificateCheckHours + if ($configuredHours -ge 1 -and $configuredHours -le 168) { + $script:CertificateCheckHours = $configuredHours + } + } catch {} +} +$script:NextCertificateCheck = (Get-Date).AddHours($script:CertificateCheckHours) + # MaxConcurrentClients ist optional, damit bestehende config.json-Dateien unverändert weiterlaufen. $script:MaxConcurrentClients = 20 if ($script:Config.Smtp.PSObject.Properties.Name -contains "MaxConcurrentClients") { @@ -1040,6 +1129,11 @@ try { while ($true) { Remove-CompletedSmtpWorkers + if ((Get-Date) -ge $script:NextCertificateCheck) { + [void](Test-RelayCertificateExpiry) + $script:NextCertificateCheck = (Get-Date).AddHours($script:CertificateCheckHours) + } + # Sollte der Queue-Worker unerwartet beendet werden, Relay nicht still # ohne Versand weiterlaufen lassen. if ($script:QueueAsyncResult.IsCompleted) {