Files
SMTPGraphRelay/Setup-SMTPGraphRelay.ps1
T

2287 lines
75 KiB
PowerShell

#Requires -Version 5.1
#Requires -RunAsAdministrator
<#
.SYNOPSIS
SMTPGraphRelay Installer / Repair / Update
.DESCRIPTION
Einheitliches Verwaltungswerkzeug für SMTPGraphRelay.
Modi:
1 - Neuinstallation
2 - Installation reparieren
3 - Relay aktualisieren
4 - Entra / Exchange RBAC prüfen
5 - Zertifikat erneuern
6 - Health Check ausführen
7 - Deinstallieren
WICHTIG:
Dieses Skript muss mit Windows PowerShell 5.1 ausgeführt werden.
#>
[CmdletBinding()]
param(
[string]$InstallPath = "$env:ProgramFiles\SMTPGraphRelay"
)
$ErrorActionPreference = "Stop"
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$TaskName = "SMTPGraphRelay"
$AppDefaultName = "SMTPGraphRelay"
$RelayFileName = "SMTPGraphRelay.ps1"
$HealthFileName = "Test-SMTPGraphRelay.ps1"
$RenewFileName = "Renew-SMTPGraphRelayCertificate.ps1"
$ConfigFileName = "config.json"
# Zentrales Gitea-Repository / Updatequelle
$RepoBaseUrl = "https://me-gitea.maieredv.cloud/MAIEREDV/SMTPGraphRelay"
$RemoteVersionUrl = "$RepoBaseUrl/raw/branch/main/version.json"
$RemoteArchiveUrl = "$RepoBaseUrl/archive/main.zip"
$RemoteRelayUrl = "$RepoBaseUrl/raw/branch/main/SMTPGraphRelay.ps1"
# Dateien, die ein Update verändern darf.
# config.json, Queue, Logs und sonstige lokale Daten sind absichtlich NICHT enthalten.
$ManagedReleaseFiles = @(
"SMTPGraphRelay.ps1",
"Test-SMTPGraphRelay.ps1",
"Renew-SMTPGraphRelayCertificate.ps1",
"Setup-SMTPGraphRelay.ps1",
"version.json",
"README.md"
)
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 Read-Default {
param([string]$Prompt, [string]$Default)
$value = Read-Host "$Prompt [Standard: $Default]"
if ([string]::IsNullOrWhiteSpace($value)) { return $Default }
return $value
}
function Confirm-Yes {
param([string]$Prompt)
$answer = Read-Host "$Prompt [j/N]"
return ($answer -match '^(?i)j|ja|y|yes$')
}
function Assert-WindowsPowerShell51 {
if ($PSVersionTable.PSEdition -ne "Desktop" -or $PSVersionTable.PSVersion.Major -ne 5) {
Write-Title "FALSCHE POWERSHELL-VERSION"
Write-Fail "Dieses Tool muss mit Windows PowerShell 5.1 ausgeführt werden."
Write-Host ""
Write-Host "Aktuell erkannt:"
Write-Host " Edition: $($PSVersionTable.PSEdition)"
Write-Host " Version: $($PSVersionTable.PSVersion)"
Write-Host ""
Write-Host "Bitte starten:"
Write-Host " C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe" -ForegroundColor Yellow
exit 1
}
Write-Ok "Windows PowerShell $($PSVersionTable.PSVersion) erkannt."
}
function Ensure-PackageProvider {
try {
if (-not (Get-PackageProvider -Name NuGet -ListAvailable -ErrorAction SilentlyContinue)) {
Write-Info "NuGet Package Provider wird installiert..."
Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force | Out-Null
}
} catch {
Write-Warn "NuGet Provider konnte nicht automatisch vorbereitet werden: $($_.Exception.Message)"
}
}
function Ensure-Modules {
param(
[switch]$IncludeExchange
)
Ensure-PackageProvider
$modules = @(
"Microsoft.Graph.Authentication",
"Microsoft.Graph.Applications"
)
if ($IncludeExchange) {
$modules += "ExchangeOnlineManagement"
}
foreach ($module in $modules) {
$existing = Get-Module -ListAvailable -Name $module |
Sort-Object Version -Descending |
Select-Object -First 1
if (-not $existing) {
Write-Info "$module fehlt. Installation für AllUsers..."
Install-Module $module -Scope AllUsers -Repository PSGallery -Force -AllowClobber
$existing = Get-Module -ListAvailable -Name $module |
Sort-Object Version -Descending |
Select-Object -First 1
}
if (-not $existing) {
throw "Modul '$module' konnte nicht installiert/gefunden werden."
}
# Schutz gegen den bereits beobachteten PowerShell-7-Pfad.
if ($existing.ModuleBase -notmatch '\\WindowsPowerShell\\Modules\\') {
Write-Warn "$module wurde gefunden, aber nicht im Windows-PowerShell-Modulpfad: $($existing.ModuleBase)"
Write-Info "Installiere das Modul nochmals explizit aus Windows PowerShell 5.1..."
Install-Module $module -Scope AllUsers -Repository PSGallery -Force -AllowClobber
}
Write-Ok "$module verfügbar."
}
}
function New-RepoStagingArea {
Enable-Tls12
$root = Join-Path $env:TEMP ("SMTPGraphRelay-repo-{0}" -f [guid]::NewGuid().ToString("N"))
$archive = Join-Path $root "main.zip"
$extract = Join-Path $root "extract"
New-Item -ItemType Directory -Path $extract -Force | Out-Null
Write-Info "Lade aktuellen Stand aus Gitea..."
Write-Info "Quelle: $RemoteArchiveUrl"
Invoke-WebRequest `
-Uri $RemoteArchiveUrl `
-OutFile $archive `
-UseBasicParsing `
-TimeoutSec 120 `
-ErrorAction Stop
if (-not (Test-Path -LiteralPath $archive)) {
throw "Gitea-Archiv wurde nicht heruntergeladen."
}
Expand-Archive `
-LiteralPath $archive `
-DestinationPath $extract `
-Force
$releaseRoot = Find-ExtractedReleaseRoot -ExtractPath $extract
# Pflichtdateien prüfen.
foreach ($required in @("SMTPGraphRelay.ps1", "version.json")) {
if (-not (Test-Path -LiteralPath (Join-Path $releaseRoot $required))) {
throw "Repository-Archiv ist unvollständig: '$required' fehlt."
}
}
$versionInfo = Get-Content `
-LiteralPath (Join-Path $releaseRoot "version.json") `
-Raw `
-Encoding UTF8 | ConvertFrom-Json
if (-not $versionInfo.Version) {
throw "version.json aus dem Repository enthält keine Version."
}
Write-Ok "Repository-Version $($versionInfo.Version) geladen."
return [pscustomobject]@{
TempRoot = $root
ReleaseRoot = $releaseRoot
VersionInfo = $versionInfo
}
}
function Remove-RepoStagingArea {
param($Staging)
if ($Staging -and $Staging.TempRoot) {
Remove-Item -LiteralPath $Staging.TempRoot -Recurse -Force -ErrorAction SilentlyContinue
}
}
function Install-RepoProgramFiles {
param(
[Parameter(Mandatory)][string]$ReleaseRoot,
[Parameter(Mandatory)][string]$TargetPath
)
New-Item -ItemType Directory -Path $TargetPath -Force | Out-Null
foreach ($name in $ManagedReleaseFiles) {
$source = Join-Path $ReleaseRoot $name
if (Test-Path -LiteralPath $source) {
Copy-Item `
-LiteralPath $source `
-Destination (Join-Path $TargetPath $name) `
-Force
Write-Ok "Installiert: $name"
}
}
}
function Get-SourceFile {
param([Parameter(Mandatory)][string]$Name)
$candidate = Join-Path $PSScriptRoot $Name
if (Test-Path -LiteralPath $candidate) {
return $candidate
}
return $null
}
function Get-PackageVersion {
$versionFile = Join-Path $PSScriptRoot "version.json"
if (-not (Test-Path -LiteralPath $versionFile)) {
return $null
}
try {
return Get-Content -LiteralPath $versionFile -Raw -Encoding UTF8 | ConvertFrom-Json
}
catch {
Write-Warn "version.json konnte nicht gelesen werden: $($_.Exception.Message)"
return $null
}
}
function Get-InstalledVersion {
param([Parameter(Mandatory)][string]$TargetPath)
$versionFile = Join-Path $TargetPath "version.json"
if (-not (Test-Path -LiteralPath $versionFile)) {
return $null
}
try {
return Get-Content -LiteralPath $versionFile -Raw -Encoding UTF8 | ConvertFrom-Json
}
catch {
return $null
}
}
function Copy-ProgramFiles {
param(
[Parameter(Mandatory)][string]$TargetPath,
[switch]$RequireRelay
)
New-Item -ItemType Directory -Path $TargetPath -Force | Out-Null
$files = @($RelayFileName, $HealthFileName, $RenewFileName, "version.json")
foreach ($name in $files) {
$source = Get-SourceFile -Name $name
if (-not $source) {
if ($name -eq $RelayFileName -and $RequireRelay) {
throw "Quelldatei '$name' wurde neben dem Installer nicht gefunden."
}
Write-Warn "Optionale Quelldatei nicht gefunden: $name"
continue
}
$destination = Join-Path $TargetPath $name
# Nicht auf sich selbst kopieren.
if ([IO.Path]::GetFullPath($source) -ne [IO.Path]::GetFullPath($destination)) {
Copy-Item -LiteralPath $source -Destination $destination -Force
}
Write-Ok "$name bereitgestellt."
}
# Installer selbst ebenfalls in den Installationsordner legen.
try {
$selfDest = Join-Path $TargetPath "Setup-SMTPGraphRelay.ps1"
if ([IO.Path]::GetFullPath($PSCommandPath) -ne [IO.Path]::GetFullPath($selfDest)) {
Copy-Item -LiteralPath $PSCommandPath -Destination $selfDest -Force
}
} catch {}
}
function Ensure-Directories {
param([Parameter(Mandatory)][string]$TargetPath)
foreach ($dir in @(
$TargetPath,
(Join-Path $TargetPath "queue"),
(Join-Path $TargetPath "queue\incoming"),
(Join-Path $TargetPath "queue\pending"),
(Join-Path $TargetPath "queue\processing"),
(Join-Path $TargetPath "failed"),
(Join-Path $TargetPath "logs")
)) {
New-Item -ItemType Directory -Path $dir -Force | Out-Null
}
Write-Ok "Programm-/Queue-/Log-Verzeichnisse vorhanden."
}
function Get-RelayConfig {
param([Parameter(Mandatory)][string]$TargetPath)
$path = Join-Path $TargetPath $ConfigFileName
if (-not (Test-Path -LiteralPath $path)) {
return $null
}
try {
return Get-Content -LiteralPath $path -Raw -Encoding UTF8 | ConvertFrom-Json
}
catch {
throw "config.json konnte nicht gelesen werden: $($_.Exception.Message)"
}
}
function Write-RelayConfig {
param(
[Parameter(Mandatory)]$Config,
[Parameter(Mandatory)][string]$TargetPath
)
$configPath = Join-Path $TargetPath $ConfigFileName
$tmp = "$configPath.tmp"
$Config | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $tmp -Encoding UTF8
Move-Item -LiteralPath $tmp -Destination $configPath -Force
Write-Ok "config.json geschrieben."
}
function Ensure-FirewallRule {
param([Parameter(Mandatory)][int]$Port)
$prefix = "SMTPGraphRelay TCP "
Get-NetFirewallRule -ErrorAction SilentlyContinue |
Where-Object { $_.DisplayName -like "$prefix*" -and $_.DisplayName -ne "$prefix$Port" } |
Remove-NetFirewallRule -ErrorAction SilentlyContinue
$ruleName = "$prefix$Port"
$existing = Get-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue
if (-not $existing) {
New-NetFirewallRule `
-DisplayName $ruleName `
-Direction Inbound `
-Action Allow `
-Protocol TCP `
-LocalPort $Port `
-Profile Any | Out-Null
Write-Ok "Firewallregel '$ruleName' erstellt."
}
else {
Write-Ok "Firewallregel '$ruleName' vorhanden."
}
}
function Ensure-ScheduledTask {
param([Parameter(Mandatory)][string]$TargetPath)
$scriptPath = Join-Path $TargetPath $RelayFileName
if (-not (Test-Path -LiteralPath $scriptPath)) {
throw "Relay-Skript fehlt: $scriptPath"
}
$psExe = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe"
$configPath = Join-Path $TargetPath $ConfigFileName
$action = New-ScheduledTaskAction `
-Execute $psExe `
-Argument "-NoLogo -NoProfile -ExecutionPolicy Bypass -File `"$scriptPath`" -ConfigPath `"$configPath`"" `
-WorkingDirectory $TargetPath
$trigger = New-ScheduledTaskTrigger -AtStartup
$principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest
$settings = New-ScheduledTaskSettingsSet `
-AllowStartIfOnBatteries `
-DontStopIfGoingOnBatteries `
-StartWhenAvailable `
-RestartCount 5 `
-RestartInterval (New-TimeSpan -Minutes 1) `
-ExecutionTimeLimit ([TimeSpan]::Zero)
Register-ScheduledTask `
-TaskName $TaskName `
-Action $action `
-Trigger $trigger `
-Principal $principal `
-Settings $settings `
-Description "Lokaler SMTP Store-and-Forward Relay zu Microsoft 365 via Microsoft Graph" `
-Force | Out-Null
Write-Ok "Scheduled Task '$TaskName' eingerichtet."
}
function Stop-RelayTask {
$task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
if (-not $task -or $task.State -ne "Running") {
return
}
$graceSeconds = 30
try {
$config = Get-RelayConfig -TargetPath $InstallPath
if ($config -and $config.Smtp.PSObject.Properties.Name -contains "GracefulShutdownSeconds") {
$configured = [int]$config.Smtp.GracefulShutdownSeconds
if ($configured -ge 5 -and $configured -le 300) {
$graceSeconds = $configured
}
}
}
catch {}
$signalPath = Join-Path $InstallPath "shutdown.request"
try {
[IO.File]::WriteAllText(
$signalPath,
([DateTime]::UtcNow.ToString("o")),
(New-Object Text.UTF8Encoding($false))
)
Write-Info "Graceful Shutdown angefordert. Warte auf Relay (max. $graceSeconds Sekunden)..."
$deadline = (Get-Date).AddSeconds($graceSeconds + 5)
while ((Get-Date) -lt $deadline) {
Start-Sleep -Milliseconds 250
$current = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
if (-not $current -or $current.State -ne "Running") {
Write-Ok "Relay sauber beendet."
return
}
}
Write-Warn "Graceful-Shutdown-Timeout erreicht. Task wird hart beendet."
Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
Start-Sleep -Milliseconds 750
}
finally {
Remove-Item -LiteralPath $signalPath -Force -ErrorAction SilentlyContinue
}
}
function Start-RelayTask {
$task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
if ($task) {
Remove-Item -LiteralPath (Join-Path $InstallPath "shutdown.request") -Force -ErrorAction SilentlyContinue
Start-ScheduledTask -TaskName $TaskName
Start-Sleep -Seconds 2
Write-Ok "Scheduled Task gestartet."
}
}
function Convert-CertToKeyCredential {
param([Parameter(Mandatory)][System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate)
return @{
Type = "AsymmetricX509Cert"
Usage = "Verify"
Key = $Certificate.GetRawCertData()
DisplayName = "SMTPGraphRelay Certificate"
StartDateTime = $Certificate.NotBefore.ToUniversalTime()
EndDateTime = $Certificate.NotAfter.ToUniversalTime()
}
}
function New-RelayCertificate {
$subject = "CN=SMTPGraphRelay-$env:COMPUTERNAME"
return New-SelfSignedCertificate `
-Subject $subject `
-CertStoreLocation "Cert:\LocalMachine\My" `
-KeyAlgorithm RSA `
-KeyLength 2048 `
-HashAlgorithm SHA256 `
-KeyExportPolicy NonExportable `
-KeySpec Signature `
-NotAfter (Get-Date).AddYears(2)
}
function Connect-RelayGraphAdmin {
param([string]$TenantId)
Import-Module Microsoft.Graph.Authentication -Force -ErrorAction Stop
Import-Module Microsoft.Graph.Applications -Force -ErrorAction Stop
Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null
if ([string]::IsNullOrWhiteSpace($TenantId)) {
Connect-MgGraph -Scopes "Application.ReadWrite.All" -NoWelcome
}
else {
Connect-MgGraph -TenantId $TenantId -Scopes "Application.ReadWrite.All" -NoWelcome
}
}
function Ensure-ExchangeRbac {
param(
[Parameter(Mandatory)][string]$TenantId,
[Parameter(Mandatory)][string]$ClientId,
[Parameter(Mandatory)][string]$ServicePrincipalObjectId,
[Parameter(Mandatory)][string]$AppName,
[Parameter(Mandatory)][string]$SenderMailbox
)
Import-Module ExchangeOnlineManagement -Force -ErrorAction Stop
Write-Info "Exchange Online Anmeldung erforderlich (Admin)."
Connect-ExchangeOnline -ShowBanner:$false -ErrorAction Stop
try {
$recipient = Get-EXORecipient -Identity $SenderMailbox -ErrorAction Stop
Write-Ok "Exchange-Empfänger gefunden: $($recipient.DisplayName)"
$exoSp = $null
try {
$exoSp = Get-ServicePrincipal -Identity $ServicePrincipalObjectId -ErrorAction Stop
}
catch {
Write-Info "Exchange Service Principal wird registriert..."
$exoSp = New-ServicePrincipal `
-AppId $ClientId `
-ObjectId $ServicePrincipalObjectId `
-DisplayName $AppName
}
if (-not $exoSp) {
throw "Exchange Service Principal konnte nicht ermittelt/erstellt werden."
}
$shortId = $ClientId.Substring(0,8)
$scopeName = "SMTPGraphRelay-$shortId-Sender"
$assignmentName = "SMTPGraphRelay-$shortId-MailSend"
$escaped = $SenderMailbox.Replace("'", "''")
$filter = "PrimarySmtpAddress -eq '$escaped'"
$scope = Get-ManagementScope -Identity $scopeName -ErrorAction SilentlyContinue
if ($scope) {
Set-ManagementScope -Identity $scopeName -RecipientRestrictionFilter $filter
}
else {
New-ManagementScope -Name $scopeName -RecipientRestrictionFilter $filter | Out-Null
}
Write-Ok "Exchange Resource Scope: $scopeName -> $SenderMailbox"
$assignment = Get-ManagementRoleAssignment -Identity $assignmentName -ErrorAction SilentlyContinue
if ($assignment) {
Set-ManagementRoleAssignment -Identity $assignmentName -CustomResourceScope $scopeName
}
else {
New-ManagementRoleAssignment `
-Name $assignmentName `
-Role "Application Mail.Send" `
-App $ServicePrincipalObjectId `
-CustomResourceScope $scopeName | Out-Null
}
Write-Ok "Exchange RBAC 'Application Mail.Send' eingerichtet."
$auth = Test-ServicePrincipalAuthorization `
-Identity $ServicePrincipalObjectId `
-Resource $SenderMailbox
$mailSend = $auth | Where-Object { $_.RoleName -eq "Application Mail.Send" } | Select-Object -First 1
if (-not $mailSend -or -not $mailSend.InScope) {
throw "RBAC-Test für '$SenderMailbox' ist nicht InScope."
}
Write-Ok "RBAC-Test: $SenderMailbox ist InScope."
}
finally {
Disconnect-ExchangeOnline -Confirm:$false -ErrorAction SilentlyContinue
}
}
function Test-NoGlobalMailSend {
param(
[Parameter(Mandatory)][string]$ServicePrincipalObjectId
)
# Microsoft Graph Service Principal
$graphSp = Get-MgServicePrincipal -Filter "appId eq '00000003-0000-0000-c000-000000000000'" -Property "id,appRoles"
if (-not $graphSp) {
Write-Warn "Microsoft Graph Service Principal konnte nicht geprüft werden."
return
}
$mailSendRole = $graphSp.AppRoles | Where-Object {
$_.Value -eq "Mail.Send" -and $_.AllowedMemberTypes -contains "Application"
} | Select-Object -First 1
if (-not $mailSendRole) {
Write-Warn "Graph AppRole Mail.Send konnte nicht aufgelöst werden."
return
}
$assignments = Get-MgServicePrincipalAppRoleAssignment `
-ServicePrincipalId $ServicePrincipalObjectId `
-All `
-ErrorAction SilentlyContinue
$global = $assignments | Where-Object {
$_.ResourceId -eq $graphSp.Id -and $_.AppRoleId -eq $mailSendRole.Id
}
if ($global) {
Write-Fail "Die App besitzt zusätzlich globale Microsoft Graph Mail.Send Application Permission."
Write-Warn "Diese globale Berechtigung würde Exchange Application RBAC additiv umgehen."
}
else {
Write-Ok "Keine globale Graph Mail.Send Application Permission vorhanden."
}
}
function Enable-Tls12 {
try {
[Net.ServicePointManager]::SecurityProtocol = `
[Net.ServicePointManager]::SecurityProtocol -bor `
[Net.SecurityProtocolType]::Tls12
} catch {}
}
function Get-RemoteVersion {
Enable-Tls12
$tempFile = Join-Path $env:TEMP ("SMTPGraphRelay-version-{0}.json" -f [guid]::NewGuid().ToString("N"))
try {
Invoke-WebRequest `
-Uri $RemoteVersionUrl `
-OutFile $tempFile `
-UseBasicParsing `
-TimeoutSec 20 `
-ErrorAction Stop
$remote = Get-Content -LiteralPath $tempFile -Raw -Encoding UTF8 | ConvertFrom-Json
if (-not $remote.Version) {
throw "Remote version.json enthält keine Version."
}
return $remote
}
finally {
Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue
}
}
function Compare-RelayVersions {
param(
[Parameter(Mandatory)][string]$Installed,
[Parameter(Mandatory)][string]$Remote
)
try {
$installedVersion = [version]$Installed
$remoteVersion = [version]$Remote
if ($remoteVersion -gt $installedVersion) { return 1 }
if ($remoteVersion -lt $installedVersion) { return -1 }
return 0
}
catch {
throw "Versionsvergleich fehlgeschlagen: installiert='$Installed', remote='$Remote'."
}
}
function Find-ExtractedReleaseRoot {
param(
[Parameter(Mandatory)][string]$ExtractPath
)
# Gitea kann beim Archiv einen zusätzlichen Root-Ordner erzeugen.
# Daher suchen wir nach der Kombination aus Relay + version.json statt
# einen konkreten Archivordnernamen vorauszusetzen.
$relayFiles = Get-ChildItem `
-LiteralPath $ExtractPath `
-Recurse `
-File `
-Filter $RelayFileName `
-ErrorAction SilentlyContinue
foreach ($relay in $relayFiles) {
$candidate = $relay.Directory.FullName
if (Test-Path -LiteralPath (Join-Path $candidate "version.json")) {
return $candidate
}
}
throw "Im heruntergeladenen Archiv wurde kein gültiges SMTPGraphRelay-Release gefunden."
}
function Backup-ManagedFiles {
param(
[Parameter(Mandatory)][string]$TargetPath
)
$backupRoot = Join-Path $TargetPath "backup"
$backupPath = Join-Path $backupRoot (Get-Date -Format "yyyyMMdd-HHmmss")
New-Item -ItemType Directory -Path $backupPath -Force | Out-Null
foreach ($name in $ManagedReleaseFiles) {
$source = Join-Path $TargetPath $name
if (Test-Path -LiteralPath $source) {
Copy-Item -LiteralPath $source -Destination (Join-Path $backupPath $name) -Force
}
}
return $backupPath
}
function Restore-ManagedFiles {
param(
[Parameter(Mandatory)][string]$BackupPath,
[Parameter(Mandatory)][string]$TargetPath
)
foreach ($name in $ManagedReleaseFiles) {
$backupFile = Join-Path $BackupPath $name
$targetFile = Join-Path $TargetPath $name
if (Test-Path -LiteralPath $backupFile) {
Copy-Item -LiteralPath $backupFile -Destination $targetFile -Force
}
}
}
function Install-ExtractedRelease {
param(
[Parameter(Mandatory)][string]$ReleaseRoot,
[Parameter(Mandatory)][string]$TargetPath
)
$required = @(
"SMTPGraphRelay.ps1",
"version.json"
)
foreach ($name in $required) {
if (-not (Test-Path -LiteralPath (Join-Path $ReleaseRoot $name))) {
throw "Updatepaket ist unvollständig: '$name' fehlt."
}
}
foreach ($name in $ManagedReleaseFiles) {
$source = Join-Path $ReleaseRoot $name
if (Test-Path -LiteralPath $source) {
Copy-Item -LiteralPath $source -Destination (Join-Path $TargetPath $name) -Force
Write-Ok "Aktualisiert: $name"
}
}
}
function Invoke-PostUpdateHealthCheck {
param(
[Parameter(Mandatory)][string]$TargetPath
)
$health = Join-Path $TargetPath $HealthFileName
if (-not (Test-Path -LiteralPath $health)) {
Write-Warn "Health Check ist nicht installiert; automatische Nachprüfung entfällt."
return 0
}
Write-Info "Starte Health Check nach dem Update..."
& $health -ConfigPath (Join-Path $TargetPath $ConfigFileName)
return $LASTEXITCODE
}
function Install-New {
Write-Title "SMTPGraphRelay - Neuinstallation aus Gitea"
if (Test-Path -LiteralPath (Join-Path $InstallPath $ConfigFileName)) {
Write-Warn "Es existiert bereits eine config.json unter:"
Write-Host " $InstallPath"
Write-Warn "Neuinstallation würde eine neue App/Zertifikat erzeugen."
if (-not (Confirm-Yes "Trotzdem fortfahren?")) {
return
}
}
$staging = $null
try {
$staging = New-RepoStagingArea
Ensure-Modules -IncludeExchange
Ensure-Directories -TargetPath $InstallPath
# Nur Programmdateien aus Git übernehmen.
Install-RepoProgramFiles `
-ReleaseRoot $staging.ReleaseRoot `
-TargetPath $InstallPath
Write-Ok "Programmdateien aus Gitea installiert."
$appName = Read-Default "Name der Entra App" $AppDefaultName
$senderMailbox = Read-Host "M365-Absenderpostfach (z.B. info@firma.de)"
while ([string]::IsNullOrWhiteSpace($senderMailbox) -or $senderMailbox -notmatch '^[^@\s]+@[^@\s]+\.[^@\s]+$') {
$senderMailbox = Read-Host "Bitte eine gültige Mailadresse eingeben"
}
$listenAddress = Read-Default "Lokale Listen-IP" "0.0.0.0"
$port = [int](Read-Default "SMTP-Port" "2525")
$allowedText = Read-Default "Erlaubte Netze, mit Komma getrennt" "127.0.0.1/32,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
$allowedNetworks = @($allowedText -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
Write-Info "Erzeuge Relay-Zertifikat..."
$cert = New-RelayCertificate
Write-Ok "Zertifikat erstellt: $($cert.Thumbprint)"
Write-Info "Microsoft Graph Anmeldung erforderlich (Entra-Admin)."
Connect-RelayGraphAdmin
try {
$tenantId = (Get-MgContext).TenantId
Write-Ok "Tenant: $tenantId"
$appParams = @{
DisplayName = $appName
SignInAudience = "AzureADMyOrg"
KeyCredentials = @(
(Convert-CertToKeyCredential -Certificate $cert)
)
}
$app = New-MgApplication -BodyParameter $appParams
Write-Ok "App Registration erstellt: $($app.AppId)"
$sp = $null
for ($i = 0; $i -lt 10 -and -not $sp; $i++) {
try {
$sp = New-MgServicePrincipal -AppId $app.AppId
}
catch {
Start-Sleep -Seconds 2
}
}
if (-not $sp) {
throw "Entra Service Principal konnte nicht erstellt werden."
}
Write-Ok "Entra Service Principal erstellt: $($sp.Id)"
Test-NoGlobalMailSend -ServicePrincipalObjectId $sp.Id
Ensure-ExchangeRbac `
-TenantId $tenantId `
-ClientId $app.AppId `
-ServicePrincipalObjectId $sp.Id `
-AppName $appName `
-SenderMailbox $senderMailbox
$config = [ordered]@{
Smtp = [ordered]@{
ListenAddress = $listenAddress
Port = $port
Hostname = $env:COMPUTERNAME
AllowedNetworks = $allowedNetworks
MaxMessageSizeMB = 25
ClientTimeoutSeconds = 120
MaxConcurrentClients = 20
MaxRecipients = 50
MaxMessagesPerConnection = 25
RequireAuth = $false
AuthMaxFailures = 5
AllowUnauthenticatedNetworks = @()
AuthUsers = @()
GracefulShutdownSeconds = 30
}
Graph = [ordered]@{
TenantId = $tenantId
ClientId = $app.AppId
CertificateThumbprint = $cert.Thumbprint
SenderMailbox = $senderMailbox
ForceSender = $true
CertificateWarningDays = 60
CertificateCriticalDays = 14
CertificateCheckHours = 12
}
Queue = [ordered]@{
PollSeconds = 10
MaxRetries = 8
RetryMinutes = @(1,5,15,30,60,120,240,480)
MaxPendingMessages = 5000
MinFreeDiskSpaceMB = 1024
}
Paths = [ordered]@{
Queue = "queue"
Failed = "failed"
Logs = "logs"
}
Logging = [ordered]@{
MaxFileSizeMB = 10
RetentionDays = 30
CleanupHours = 12
}
Update = [ordered]@{
Repository = $RepoBaseUrl
Branch = "main"
}
}
Write-RelayConfig -Config $config -TargetPath $InstallPath
Ensure-FirewallRule -Port $port
Ensure-ScheduledTask -TargetPath $InstallPath
Write-Info "Teste App-only Anmeldung mit Relay-Zertifikat..."
Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null
Connect-MgGraph `
-TenantId $tenantId `
-ClientId $app.AppId `
-Certificate $cert `
-NoWelcome | Out-Null
$ctx = Get-MgContext
if (-not $ctx -or $ctx.AuthType -ne "AppOnly") {
throw "App-only Anmeldung konnte nicht bestätigt werden."
}
Write-Ok "App-only Anmeldung funktioniert."
Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null
Start-RelayTask
$health = Join-Path $InstallPath $HealthFileName
if (Test-Path -LiteralPath $health) {
Write-Info "Starte abschließenden Health Check..."
& $health -ConfigPath (Join-Path $InstallPath $ConfigFileName)
$healthExit = $LASTEXITCODE
if ($healthExit -ge 2) {
Write-Warn "Installation abgeschlossen, Health Check meldet Fehler. Bitte Ausgabe prüfen."
}
}
Write-Title "Neuinstallation abgeschlossen"
Write-Host "Version: $($staging.VersionInfo.Version)"
Write-Host "Installationspfad: $InstallPath"
Write-Host "Client ID: $($app.AppId)"
Write-Host "Tenant ID: $tenantId"
Write-Host "Sender: $senderMailbox"
Write-Host "SMTP: $listenAddress`:$port"
}
finally {
Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null
}
}
finally {
Remove-RepoStagingArea -Staging $staging
}
}
function Repair-Installation {
Write-Title "SMTPGraphRelay - Repair aus Gitea"
$config = Get-RelayConfig -TargetPath $InstallPath
if (-not $config) {
Write-Fail "Keine config.json gefunden. Repair ist nur für bestehende Installationen gedacht."
Write-Info "Bitte Neuinstallation verwenden."
return
}
$staging = $null
$backupPath = $null
try {
$staging = New-RepoStagingArea
Ensure-Modules
Ensure-Directories -TargetPath $InstallPath
$certPath = "Cert:\LocalMachine\My\$($config.Graph.CertificateThumbprint)"
$cert = Get-Item -LiteralPath $certPath -ErrorAction SilentlyContinue
if (-not $cert) {
Write-Fail "Konfiguriertes Zertifikat fehlt: $($config.Graph.CertificateThumbprint)"
Write-Warn "Repair erzeugt absichtlich kein neues Credential. Nutze Zertifikat erneuern."
}
elseif (-not $cert.HasPrivateKey) {
Write-Fail "Konfiguriertes Zertifikat besitzt keinen privaten Schlüssel."
}
else {
Write-Ok "Zertifikat vorhanden und besitzt Private Key."
}
Write-Info "Sichere aktuelle Programmdateien..."
$backupPath = Backup-ManagedFiles -TargetPath $InstallPath
Write-Ok "Backup: $backupPath"
Stop-RelayTask
Install-RepoProgramFiles `
-ReleaseRoot $staging.ReleaseRoot `
-TargetPath $InstallPath
Ensure-FirewallRule -Port ([int]$config.Smtp.Port)
Ensure-ScheduledTask -TargetPath $InstallPath
Start-RelayTask
$healthExit = Invoke-PostUpdateHealthCheck -TargetPath $InstallPath
if ($healthExit -ge 2) {
throw "Health Check nach Repair meldet FEHLER (ExitCode $healthExit)."
}
if ($healthExit -eq 1) {
Write-Warn "Repair abgeschlossen, Health Check enthält Warnungen."
}
else {
Write-Ok "Repair Health Check erfolgreich."
}
Write-Title "Repair abgeschlossen"
Write-Host "Installierte Repo-Version: $($staging.VersionInfo.Version)"
}
catch {
Write-Fail "Repair fehlgeschlagen: $($_.Exception.Message)"
if ($backupPath -and (Test-Path -LiteralPath $backupPath)) {
Write-Warn "Stelle vorherige Programmdateien wieder her..."
try {
Stop-RelayTask
Restore-ManagedFiles -BackupPath $backupPath -TargetPath $InstallPath
Ensure-ScheduledTask -TargetPath $InstallPath
Start-RelayTask
Write-Ok "Rollback nach Repair abgeschlossen."
}
catch {
Write-Fail "Repair-Rollback fehlgeschlagen: $($_.Exception.Message)"
}
}
}
finally {
Remove-RepoStagingArea -Staging $staging
}
}
function Update-Relay {
Write-Title "SMTPGraphRelay - Online Update"
$config = Get-RelayConfig -TargetPath $InstallPath
if (-not $config) {
Write-Fail "Keine bestehende config.json gefunden."
Write-Info "Für eine neue Installation bitte 'Neuinstallation' wählen."
return
}
$installedVersionInfo = Get-InstalledVersion -TargetPath $InstallPath
$installedVersion = $null
if ($installedVersionInfo -and $installedVersionInfo.Version) {
$installedVersion = [string]$installedVersionInfo.Version
Write-Info "Installierte Version: $installedVersion"
}
else {
Write-Warn "Keine installierte version.json gefunden."
$installedVersion = Read-Host "Installierte Version manuell eingeben (z.B. 1.5.0)"
if ([string]::IsNullOrWhiteSpace($installedVersion)) {
Write-Fail "Ohne lokale Versionsinformation kann kein sicheres Online-Update durchgeführt werden."
return
}
}
Write-Info "Prüfe Gitea auf neue Version..."
Write-Info "Repository: $RepoBaseUrl"
try {
$remoteInfo = Get-RemoteVersion
}
catch {
Write-Fail "Remote-Version konnte nicht geladen werden: $($_.Exception.Message)"
return
}
$remoteVersion = [string]$remoteInfo.Version
Write-Ok "Remote-Version: $remoteVersion"
try {
$comparison = Compare-RelayVersions -Installed $installedVersion -Remote $remoteVersion
}
catch {
Write-Fail $_.Exception.Message
return
}
if ($comparison -eq 0) {
Write-Ok "SMTPGraphRelay ist bereits aktuell ($installedVersion)."
return
}
if ($comparison -lt 0) {
Write-Warn "Die installierte Version ($installedVersion) ist neuer als main ($remoteVersion)."
if (-not (Confirm-Yes "Downgrade auf $remoteVersion durchführen?")) {
return
}
}
else {
Write-Host ""
Write-Host "Update verfügbar:" -ForegroundColor Green
Write-Host " Installiert: $installedVersion"
Write-Host " Neu: $remoteVersion" -ForegroundColor Yellow
Write-Host ""
if (-not (Confirm-Yes "Update auf $remoteVersion installieren?")) {
return
}
}
Enable-Tls12
$updateRoot = Join-Path $env:TEMP ("SMTPGraphRelay-update-{0}" -f [guid]::NewGuid().ToString("N"))
$archivePath = Join-Path $updateRoot "main.zip"
$extractPath = Join-Path $updateRoot "extract"
New-Item -ItemType Directory -Path $updateRoot -Force | Out-Null
New-Item -ItemType Directory -Path $extractPath -Force | Out-Null
$backupPath = $null
$taskWasRunning = $false
try {
Write-Info "Lade Repository-Archiv..."
Invoke-WebRequest `
-Uri $RemoteArchiveUrl `
-OutFile $archivePath `
-UseBasicParsing `
-TimeoutSec 120 `
-ErrorAction Stop
if (-not (Test-Path -LiteralPath $archivePath)) {
throw "Download des Updatearchivs fehlgeschlagen."
}
Write-Ok "Archiv heruntergeladen."
Expand-Archive `
-LiteralPath $archivePath `
-DestinationPath $extractPath `
-Force
$releaseRoot = Find-ExtractedReleaseRoot -ExtractPath $extractPath
Write-Ok "Release im Archiv gefunden: $releaseRoot"
$downloadedVersionInfo = Get-Content `
-LiteralPath (Join-Path $releaseRoot "version.json") `
-Raw `
-Encoding UTF8 | ConvertFrom-Json
if (-not $downloadedVersionInfo.Version) {
throw "version.json im Archiv enthält keine Version."
}
if ([string]$downloadedVersionInfo.Version -ne $remoteVersion) {
throw "Versionskonflikt: version.json-URL meldet $remoteVersion, Archiv enthält $($downloadedVersionInfo.Version)."
}
$task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
if ($task -and $task.State -eq "Running") {
$taskWasRunning = $true
}
Write-Info "Erstelle Backup der verwalteten Programmdateien..."
$backupPath = Backup-ManagedFiles -TargetPath $InstallPath
Write-Ok "Backup: $backupPath"
Stop-RelayTask
Write-Info "Installiere Release $remoteVersion..."
Install-ExtractedRelease `
-ReleaseRoot $releaseRoot `
-TargetPath $InstallPath
Ensure-Directories -TargetPath $InstallPath
Ensure-FirewallRule -Port ([int]$config.Smtp.Port)
Ensure-ScheduledTask -TargetPath $InstallPath
Start-RelayTask
$healthExit = Invoke-PostUpdateHealthCheck -TargetPath $InstallPath
if ($healthExit -ge 2) {
throw "Health Check nach Update meldet FEHLER (ExitCode $healthExit)."
}
if ($healthExit -eq 1) {
Write-Warn "Update erfolgreich, Health Check enthält Warnungen."
}
else {
Write-Ok "Health Check nach Update erfolgreich."
}
Write-Title "Online Update abgeschlossen"
Write-Host "Vorher: $installedVersion"
Write-Host "Jetzt: $remoteVersion" -ForegroundColor Green
Write-Host "Backup: $backupPath"
}
catch {
Write-Host ""
Write-Fail "Update fehlgeschlagen: $($_.Exception.Message)"
if ($backupPath -and (Test-Path -LiteralPath $backupPath)) {
Write-Warn "Automatischer Rollback wird durchgeführt..."
try {
Stop-RelayTask
Restore-ManagedFiles `
-BackupPath $backupPath `
-TargetPath $InstallPath
Ensure-ScheduledTask -TargetPath $InstallPath
Start-RelayTask
Write-Ok "Rollback abgeschlossen."
}
catch {
Write-Fail "Rollback fehlgeschlagen: $($_.Exception.Message)"
Write-Warn "Backup liegt unter: $backupPath"
}
}
}
finally {
Remove-Item -LiteralPath $updateRoot -Recurse -Force -ErrorAction SilentlyContinue
}
}
function Verify-CloudRbac {
Write-Title "SMTPGraphRelay - Entra / Exchange RBAC prüfen"
$config = Get-RelayConfig -TargetPath $InstallPath
if (-not $config) {
Write-Fail "config.json nicht gefunden."
return
}
Ensure-Modules -IncludeExchange
Write-Info "Microsoft Graph Anmeldung erforderlich (Entra-Admin)."
Connect-RelayGraphAdmin -TenantId $config.Graph.TenantId
try {
$app = Get-MgApplication -Filter "appId eq '$($config.Graph.ClientId)'" -Property "id,appId,displayName,keyCredentials" |
Select-Object -First 1
if (-not $app) {
Write-Fail "App Registration mit ClientId $($config.Graph.ClientId) nicht gefunden."
return
}
Write-Ok "App Registration gefunden: $($app.DisplayName)"
$sp = Get-MgServicePrincipal -Filter "appId eq '$($config.Graph.ClientId)'" -Property "id,appId,displayName" |
Select-Object -First 1
if (-not $sp) {
Write-Fail "Entra Service Principal nicht gefunden."
return
}
Write-Ok "Entra Service Principal gefunden: $($sp.Id)"
Test-NoGlobalMailSend -ServicePrincipalObjectId $sp.Id
Import-Module ExchangeOnlineManagement -Force -ErrorAction Stop
Write-Info "Exchange Online Anmeldung erforderlich (Admin)."
Connect-ExchangeOnline -ShowBanner:$false -ErrorAction Stop
try {
$auth = Test-ServicePrincipalAuthorization `
-Identity $sp.Id `
-Resource $config.Graph.SenderMailbox
$role = $auth | Where-Object { $_.RoleName -eq "Application Mail.Send" } | Select-Object -First 1
if ($role -and $role.InScope) {
Write-Ok "Exchange Application Mail.Send: SenderMailbox ist InScope."
if ($role.AllowedResourceScope) {
Write-Info "AllowedResourceScope: $($role.AllowedResourceScope)"
}
}
else {
Write-Fail "Exchange Application Mail.Send fehlt oder SenderMailbox ist nicht InScope."
}
}
finally {
Disconnect-ExchangeOnline -Confirm:$false -ErrorAction SilentlyContinue
}
}
finally {
Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null
}
}
function Invoke-CertificateRenewal {
Write-Title "SMTPGraphRelay - Zertifikat erneuern"
$renew = Join-Path $InstallPath $RenewFileName
if (-not (Test-Path -LiteralPath $renew)) {
Write-Fail "$RenewFileName ist nicht installiert."
return
}
& $renew -ConfigPath (Join-Path $InstallPath $ConfigFileName)
}
function Invoke-HealthCheck {
Write-Title "SMTPGraphRelay - Health Check"
$health = Join-Path $InstallPath $HealthFileName
if (-not (Test-Path -LiteralPath $health)) {
Write-Fail "$HealthFileName ist nicht installiert."
return
}
& $health -ConfigPath (Join-Path $InstallPath $ConfigFileName)
}
function ConvertTo-PlainText {
param([Parameter(Mandatory)][Security.SecureString]$SecureString)
$ptr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($SecureString)
try {
return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($ptr)
}
finally {
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ptr)
}
}
function Invoke-Pbkdf2Sha256 {
param(
[Parameter(Mandatory)][string]$Password,
[Parameter(Mandatory)][byte[]]$Salt,
[Parameter(Mandatory)][int]$Iterations,
[int]$Length = 32
)
if ($Iterations -lt 1) {
throw "Iterations must be greater than zero."
}
# Auf unterstützten .NET-Framework-Versionen verwenden wir die native,
# schnelle PBKDF2-SHA256-Implementierung.
try {
$derive = New-Object System.Security.Cryptography.Rfc2898DeriveBytes(
$Password,
$Salt,
$Iterations,
[System.Security.Cryptography.HashAlgorithmName]::SHA256
)
try {
return $derive.GetBytes($Length)
}
finally {
$derive.Dispose()
}
}
catch {
# Kompatibilitäts-Fallback für ältere .NET-Framework-Stände.
$hmac = New-Object System.Security.Cryptography.HMACSHA256
$hmac.Key = [Text.Encoding]::UTF8.GetBytes($Password)
try {
$hashLength = 32
$blocks = [Math]::Ceiling($Length / [double]$hashLength)
$output = New-Object byte[] ($blocks * $hashLength)
$offset = 0
for ($block = 1; $block -le $blocks; $block++) {
$blockBytes = [BitConverter]::GetBytes([int]$block)
if ([BitConverter]::IsLittleEndian) {
[Array]::Reverse($blockBytes)
}
$input = New-Object byte[] ($Salt.Length + 4)
[Array]::Copy($Salt, 0, $input, 0, $Salt.Length)
[Array]::Copy($blockBytes, 0, $input, $Salt.Length, 4)
$u = $hmac.ComputeHash($input)
$t = New-Object byte[] $u.Length
[Array]::Copy($u, $t, $u.Length)
for ($i = 2; $i -le $Iterations; $i++) {
$u = $hmac.ComputeHash($u)
for ($j = 0; $j -lt $t.Length; $j++) {
$t[$j] = $t[$j] -bxor $u[$j]
}
}
[Array]::Copy($t, 0, $output, $offset, $t.Length)
$offset += $t.Length
}
$result = New-Object byte[] $Length
[Array]::Copy($output, 0, $result, 0, $Length)
return $result
}
finally {
$hmac.Dispose()
}
}
}
function New-SmtpPasswordRecord {
param([Parameter(Mandatory)][Security.SecureString]$Password)
$plain = ConvertTo-PlainText -SecureString $Password
try {
$salt = New-Object byte[] 16
$rng = [Security.Cryptography.RandomNumberGenerator]::Create()
try {
$rng.GetBytes($salt)
}
finally {
$rng.Dispose()
}
$iterations = 150000
$hash = Invoke-Pbkdf2Sha256 `
-Password $plain `
-Salt $salt `
-Iterations $iterations `
-Length 32
return [pscustomobject]@{
Salt = [Convert]::ToBase64String($salt)
PasswordHash = [Convert]::ToBase64String($hash)
Iterations = $iterations
}
}
finally {
$plain = $null
}
}
function Ensure-SmtpAuthConfig {
param([Parameter(Mandatory)]$Config)
if (-not ($Config.Smtp.PSObject.Properties.Name -contains "RequireAuth")) {
$Config.Smtp | Add-Member -NotePropertyName RequireAuth -NotePropertyValue $false
}
if (-not ($Config.Smtp.PSObject.Properties.Name -contains "AuthMaxFailures")) {
$Config.Smtp | Add-Member -NotePropertyName AuthMaxFailures -NotePropertyValue 5
}
if (-not ($Config.Smtp.PSObject.Properties.Name -contains "AllowUnauthenticatedNetworks")) {
$Config.Smtp | Add-Member -NotePropertyName AllowUnauthenticatedNetworks -NotePropertyValue @()
}
if (-not ($Config.Smtp.PSObject.Properties.Name -contains "AuthUsers")) {
$Config.Smtp | Add-Member -NotePropertyName AuthUsers -NotePropertyValue @()
}
return $Config
}
function Save-SmtpAuthConfigAndRestart {
param([Parameter(Mandatory)]$Config)
Write-RelayConfig -Config $Config -TargetPath $InstallPath
Ensure-ScheduledTask -TargetPath $InstallPath
Stop-RelayTask
Start-RelayTask
}
function Get-FailedQueuePath {
param([Parameter(Mandatory)]$Config)
$path = [string]$Config.Paths.Failed
if ([IO.Path]::IsPathRooted($path)) {
return $path
}
return (Join-Path $InstallPath $path)
}
function Get-PendingQueuePath {
param([Parameter(Mandatory)]$Config)
$path = [string]$Config.Paths.Queue
if (-not [IO.Path]::IsPathRooted($path)) {
$path = Join-Path $InstallPath $path
}
return (Join-Path $path "pending")
}
function Get-FailedQueueEntries {
param([Parameter(Mandatory)]$Config)
$failedPath = Get-FailedQueuePath -Config $Config
if (-not (Test-Path -LiteralPath $failedPath)) {
return @()
}
$entries = @()
foreach ($file in Get-ChildItem -LiteralPath $failedPath -Filter "*.eml" -File -ErrorAction SilentlyContinue | Sort-Object LastWriteTime) {
$metaPath = "$($file.FullName).json"
$meta = $null
if (Test-Path -LiteralPath $metaPath) {
try {
$meta = Get-Content -LiteralPath $metaPath -Raw -Encoding UTF8 | ConvertFrom-Json
}
catch {}
}
$queueId = [IO.Path]::GetFileNameWithoutExtension($file.Name)
if ($meta -and $meta.PSObject.Properties.Name -contains "QueueId" -and $meta.QueueId) {
$queueId = [string]$meta.QueueId
}
$entries += [pscustomobject]@{
QueueId = $queueId
FileName = $file.Name
Path = $file.FullName
MetaPath = $metaPath
SizeKB = [Math]::Round($file.Length / 1KB, 1)
FailedSince = $file.LastWriteTime
RetryCount = if ($meta -and $meta.RetryCount -ne $null) { [int]$meta.RetryCount } else { $null }
From = if ($meta) { [string]$meta.EnvelopeFrom } else { "" }
Recipients = if ($meta) { (@($meta.EnvelopeRecipients) -join ", ") } else { "" }
LastStatusCode = if ($meta) { [string]$meta.LastStatusCode } else { "" }
LastError = if ($meta) { [string]$meta.LastError } else { "" }
AuthenticatedUser = if ($meta -and $meta.PSObject.Properties.Name -contains "AuthenticatedUser") { [string]$meta.AuthenticatedUser } else { "" }
Meta = $meta
}
}
return @($entries)
}
function Retry-FailedQueueEntry {
param(
[Parameter(Mandatory)]$Config,
[Parameter(Mandatory)]$Entry
)
$pendingPath = Get-PendingQueuePath -Config $Config
New-Item -ItemType Directory -Path $pendingPath -Force | Out-Null
$targetEml = Join-Path $pendingPath $Entry.FileName
$targetMeta = "$targetEml.json"
if (Test-Path -LiteralPath $targetEml) {
throw "Pending enthält bereits '$($Entry.FileName)'."
}
if ($Entry.Meta) {
$meta = $Entry.Meta
if ($meta.PSObject.Properties.Name -contains "RetryCount") {
$meta.RetryCount = 0
} else {
$meta | Add-Member -NotePropertyName RetryCount -NotePropertyValue 0
}
$now = [DateTime]::UtcNow.ToString("o")
if ($meta.PSObject.Properties.Name -contains "NextAttemptUtc") {
$meta.NextAttemptUtc = $now
} else {
$meta | Add-Member -NotePropertyName NextAttemptUtc -NotePropertyValue $now
}
if ($meta.PSObject.Properties.Name -contains "RequeuedUtc") {
$meta.RequeuedUtc = $now
} else {
$meta | Add-Member -NotePropertyName RequeuedUtc -NotePropertyValue $now
}
$meta | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $Entry.MetaPath -Encoding UTF8
}
if (Test-Path -LiteralPath $Entry.MetaPath) {
Move-Item -LiteralPath $Entry.MetaPath -Destination $targetMeta -Force
}
Move-Item -LiteralPath $Entry.Path -Destination $targetEml -Force
}
function Remove-FailedQueueEntry {
param([Parameter(Mandatory)]$Entry)
Remove-Item -LiteralPath $Entry.Path -Force -ErrorAction Stop
Remove-Item -LiteralPath $Entry.MetaPath -Force -ErrorAction SilentlyContinue
}
function Manage-FailedQueue {
$config = Get-RelayConfig -TargetPath $InstallPath
if (-not $config) {
Write-Fail "config.json nicht gefunden."
return
}
while ($true) {
Clear-Host
Write-Title "SMTPGraphRelay - Failed Queue"
$entries = @(Get-FailedQueueEntries -Config $config)
Write-Host "Failed-Mails: $($entries.Count)"
Write-Host ""
Write-Host " [1] Failed Queue anzeigen"
Write-Host " [2] Details einer Mail anzeigen"
Write-Host " [3] Eine Mail erneut zustellen"
Write-Host " [4] Alle Mails erneut zustellen"
Write-Host " [5] Eine Mail endgültig löschen"
Write-Host " [6] Alle Failed-Mails endgültig löschen"
Write-Host " [0] Zurück"
Write-Host ""
$choice = Read-Host "Auswahl"
switch ($choice) {
"1" {
if ($entries.Count -eq 0) {
Write-Ok "Failed Queue ist leer."
}
else {
$entries |
Select-Object QueueId,FailedSince,RetryCount,From,Recipients,LastStatusCode,SizeKB |
Format-Table -AutoSize
}
Read-Host "Enter"
}
"2" {
$id = Read-Host "Queue-ID"
$entry = $entries |
Where-Object { $_.QueueId -eq $id -or $_.FileName -eq $id -or $_.FileName -eq "$id.eml" } |
Select-Object -First 1
if (-not $entry) {
Write-Fail "Queue-ID '$id' nicht gefunden."
}
else {
Write-Host ""
Write-Host "Queue-ID: $($entry.QueueId)"
Write-Host "Datei: $($entry.FileName)"
Write-Host "Fehlgeschlagen:$($entry.FailedSince)"
Write-Host "RetryCount: $($entry.RetryCount)"
Write-Host "Von: $($entry.From)"
Write-Host "An: $($entry.Recipients)"
Write-Host "Auth-User: $($entry.AuthenticatedUser)"
Write-Host "Status: $($entry.LastStatusCode)"
Write-Host "Größe: $($entry.SizeKB) KB"
Write-Host ""
Write-Host "Letzter Fehler:" -ForegroundColor Yellow
Write-Host $entry.LastError
}
Read-Host "Enter"
}
"3" {
$id = Read-Host "Queue-ID"
$entry = $entries |
Where-Object { $_.QueueId -eq $id -or $_.FileName -eq $id -or $_.FileName -eq "$id.eml" } |
Select-Object -First 1
if (-not $entry) {
Write-Fail "Queue-ID '$id' nicht gefunden."
}
else {
Retry-FailedQueueEntry -Config $config -Entry $entry
Write-Ok "[$($entry.QueueId)] zurück nach pending verschoben."
}
Read-Host "Enter"
}
"4" {
if ($entries.Count -eq 0) {
Write-Ok "Failed Queue ist leer."
}
elseif (Confirm-Yes "Alle $($entries.Count) Failed-Mails erneut zustellen?") {
$ok = 0
$failed = 0
foreach ($entry in $entries) {
try {
Retry-FailedQueueEntry -Config $config -Entry $entry
$ok++
}
catch {
$failed++
Write-Warn "[$($entry.QueueId)] konnte nicht requeued werden: $($_.Exception.Message)"
}
}
Write-Ok "$ok Mail(s) zurück nach pending verschoben."
if ($failed -gt 0) {
Write-Warn "$failed Mail(s) konnten nicht verschoben werden."
}
}
Read-Host "Enter"
}
"5" {
$id = Read-Host "Queue-ID"
$entry = $entries |
Where-Object { $_.QueueId -eq $id -or $_.FileName -eq $id -or $_.FileName -eq "$id.eml" } |
Select-Object -First 1
if (-not $entry) {
Write-Fail "Queue-ID '$id' nicht gefunden."
}
elseif (Confirm-Yes "[$($entry.QueueId)] endgültig aus Failed löschen?") {
Remove-FailedQueueEntry -Entry $entry
Write-Ok "[$($entry.QueueId)] gelöscht."
}
Read-Host "Enter"
}
"6" {
if ($entries.Count -eq 0) {
Write-Ok "Failed Queue ist leer."
}
elseif (Confirm-Yes "WIRKLICH alle $($entries.Count) Failed-Mails endgültig löschen?") {
if (Confirm-Yes "Endgültiges Löschen nochmals bestätigen?") {
$ok = 0
foreach ($entry in $entries) {
try {
Remove-FailedQueueEntry -Entry $entry
$ok++
}
catch {
Write-Warn "[$($entry.QueueId)] konnte nicht gelöscht werden."
}
}
Write-Ok "$ok Failed-Mail(s) endgültig gelöscht."
}
}
Read-Host "Enter"
}
"0" {
return
}
default {
Write-Warn "Ungültige Auswahl."
Start-Sleep -Seconds 1
}
}
$config = Get-RelayConfig -TargetPath $InstallPath
}
}
function Manage-SmtpAuth {
$config = Get-RelayConfig -TargetPath $InstallPath
if (-not $config) {
Write-Fail "config.json nicht gefunden."
return
}
$config = Ensure-SmtpAuthConfig -Config $config
while ($true) {
Clear-Host
Write-Title "SMTPGraphRelay - SMTP-AUTH"
$users = @($config.Smtp.AuthUsers)
$authState = if ([bool]$config.Smtp.RequireAuth) { "ERFORDERLICH" } else { "optional / nicht erforderlich" }
Write-Host "Status: $authState"
Write-Host "Benutzer: $($users.Count)"
Write-Host "Max. Fehlversuche: $($config.Smtp.AuthMaxFailures)"
Write-Host "Ohne Auth erlaubte Netze: $(@($config.Smtp.AllowUnauthenticatedNetworks) -join ', ')"
Write-Host ""
Write-Host " [1] SMTP-AUTH erforderlich EIN/AUS"
Write-Host " [2] Benutzer hinzufügen"
Write-Host " [3] Benutzer anzeigen"
Write-Host " [4] Passwort ändern"
Write-Host " [5] Benutzer löschen"
Write-Host " [6] Netze ohne Auth verwalten"
Write-Host " [7] Max. Fehlversuche ändern"
Write-Host " [0] Zurück"
Write-Host ""
$choice = Read-Host "Auswahl"
switch ($choice) {
"1" {
$config.Smtp.RequireAuth = -not [bool]$config.Smtp.RequireAuth
if ($config.Smtp.RequireAuth -and @($config.Smtp.AuthUsers).Count -eq 0) {
Write-Warn "AUTH wurde aktiviert, aber es existiert noch kein SMTP-Benutzer."
}
Save-SmtpAuthConfigAndRestart -Config $config
Write-Ok "SMTP-AUTH Status geändert."
Read-Host "Enter"
}
"2" {
$username = Read-Host "Benutzername"
if ([string]::IsNullOrWhiteSpace($username) -or $username -notmatch '^[A-Za-z0-9._@-]{1,128}$') {
Write-Fail "Ungültiger Benutzername."
Read-Host "Enter"
continue
}
$existing = @($config.Smtp.AuthUsers) |
Where-Object { ([string]$_.Username).Equals($username, [StringComparison]::OrdinalIgnoreCase) } |
Select-Object -First 1
if ($existing) {
Write-Fail "Benutzer '$username' existiert bereits."
Read-Host "Enter"
continue
}
$p1 = Read-Host "Passwort" -AsSecureString
$p2 = Read-Host "Passwort wiederholen" -AsSecureString
$plain1 = ConvertTo-PlainText -SecureString $p1
$plain2 = ConvertTo-PlainText -SecureString $p2
try {
if ($plain1.Length -lt 8) {
Write-Fail "Passwort muss mindestens 8 Zeichen lang sein."
Read-Host "Enter"
continue
}
if ($plain1 -cne $plain2) {
Write-Fail "Passwörter stimmen nicht überein."
Read-Host "Enter"
continue
}
}
finally {
$plain1 = $null
$plain2 = $null
}
$record = New-SmtpPasswordRecord -Password $p1
$newUser = [pscustomobject]@{
Username = $username
Salt = $record.Salt
PasswordHash = $record.PasswordHash
Iterations = $record.Iterations
}
$config.Smtp.AuthUsers = @($config.Smtp.AuthUsers) + @($newUser)
Save-SmtpAuthConfigAndRestart -Config $config
Write-Ok "SMTP-Benutzer '$username' angelegt."
Read-Host "Enter"
}
"3" {
Write-Host ""
if (@($config.Smtp.AuthUsers).Count -eq 0) {
Write-Warn "Keine SMTP-Benutzer vorhanden."
}
else {
@($config.Smtp.AuthUsers) |
Select-Object Username,Iterations |
Format-Table -AutoSize
}
Read-Host "Enter"
}
"4" {
$username = Read-Host "Benutzername"
$user = @($config.Smtp.AuthUsers) |
Where-Object { ([string]$_.Username).Equals($username, [StringComparison]::OrdinalIgnoreCase) } |
Select-Object -First 1
if (-not $user) {
Write-Fail "Benutzer '$username' nicht gefunden."
Read-Host "Enter"
continue
}
$p1 = Read-Host "Neues Passwort" -AsSecureString
$p2 = Read-Host "Passwort wiederholen" -AsSecureString
$plain1 = ConvertTo-PlainText -SecureString $p1
$plain2 = ConvertTo-PlainText -SecureString $p2
try {
if ($plain1.Length -lt 8) {
Write-Fail "Passwort muss mindestens 8 Zeichen lang sein."
Read-Host "Enter"
continue
}
if ($plain1 -cne $plain2) {
Write-Fail "Passwörter stimmen nicht überein."
Read-Host "Enter"
continue
}
}
finally {
$plain1 = $null
$plain2 = $null
}
$record = New-SmtpPasswordRecord -Password $p1
$user.Salt = $record.Salt
$user.PasswordHash = $record.PasswordHash
$user.Iterations = $record.Iterations
Save-SmtpAuthConfigAndRestart -Config $config
Write-Ok "Passwort für '$username' geändert."
Read-Host "Enter"
}
"5" {
$username = Read-Host "Benutzername"
$found = @($config.Smtp.AuthUsers) |
Where-Object { ([string]$_.Username).Equals($username, [StringComparison]::OrdinalIgnoreCase) }
if (-not $found) {
Write-Fail "Benutzer '$username' nicht gefunden."
Read-Host "Enter"
continue
}
if (Confirm-Yes "Benutzer '$username' wirklich löschen?") {
$config.Smtp.AuthUsers = @(
$config.Smtp.AuthUsers |
Where-Object { -not ([string]$_.Username).Equals($username, [StringComparison]::OrdinalIgnoreCase) }
)
Save-SmtpAuthConfigAndRestart -Config $config
Write-Ok "Benutzer '$username' gelöscht."
}
Read-Host "Enter"
}
"6" {
$current = @($config.Smtp.AllowUnauthenticatedNetworks) -join ","
$input = Read-Default "Netze/IPs ohne AUTH, Komma getrennt; '-' für keine" $(if ($current) { $current } else { "-" })
if ($input -eq "-") {
$config.Smtp.AllowUnauthenticatedNetworks = @()
}
else {
$config.Smtp.AllowUnauthenticatedNetworks = @(
$input -split "," |
ForEach-Object { $_.Trim() } |
Where-Object { $_ }
)
}
Save-SmtpAuthConfigAndRestart -Config $config
Write-Ok "Ausnahmen für SMTP-AUTH gespeichert."
Read-Host "Enter"
}
"7" {
$value = Read-Host "Maximale Fehlversuche pro Verbindung [aktuell: $($config.Smtp.AuthMaxFailures)]"
if ($value -match '^\d+$' -and [int]$value -ge 1 -and [int]$value -le 100) {
$config.Smtp.AuthMaxFailures = [int]$value
Save-SmtpAuthConfigAndRestart -Config $config
Write-Ok "Maximale Fehlversuche geändert."
}
else {
Write-Fail "Bitte einen Wert zwischen 1 und 100 eingeben."
}
Read-Host "Enter"
}
"0" {
return
}
default {
Write-Warn "Ungültige Auswahl."
Start-Sleep -Seconds 1
}
}
# Config nach jeder Änderung neu laden, damit Serialisierung/Arrays exakt
# dem installierten Zustand entsprechen.
$config = Get-RelayConfig -TargetPath $InstallPath
$config = Ensure-SmtpAuthConfig -Config $config
}
}
function Uninstall-Relay {
Write-Title "SMTPGraphRelay - Deinstallation"
$config = Get-RelayConfig -TargetPath $InstallPath
Write-Warn "Lokale Deinstallation entfernt Task, Firewallregel und auf Wunsch das lokale Zertifikat."
if (-not (Confirm-Yes "Lokale SMTPGraphRelay-Installation wirklich entfernen?")) {
return
}
Stop-RelayTask
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
Write-Ok "Scheduled Task entfernt."
Get-NetFirewallRule -ErrorAction SilentlyContinue |
Where-Object { $_.DisplayName -like "SMTPGraphRelay TCP *" } |
Remove-NetFirewallRule -ErrorAction SilentlyContinue
Write-Ok "SMTPGraphRelay Firewallregeln entfernt."
if ($config -and $config.Graph.CertificateThumbprint) {
if (Confirm-Yes "Lokales Relay-Zertifikat $($config.Graph.CertificateThumbprint) entfernen?") {
Remove-Item -LiteralPath "Cert:\LocalMachine\My\$($config.Graph.CertificateThumbprint)" -Force -ErrorAction SilentlyContinue
Write-Ok "Lokales Zertifikat entfernt."
}
}
if ($config -and (Confirm-Yes "Auch Entra-App und Exchange-RBAC-Objekte entfernen?")) {
Ensure-Modules -IncludeExchange
Write-Warn "Cloud-Cleanup ist destruktiv und betrifft die konfigurierte ClientId:"
Write-Host " $($config.Graph.ClientId)" -ForegroundColor Yellow
if (Confirm-Yes "Cloud-Cleanup endgültig bestätigen?") {
Connect-RelayGraphAdmin -TenantId $config.Graph.TenantId
try {
$app = Get-MgApplication -Filter "appId eq '$($config.Graph.ClientId)'" -Property "id,appId,displayName" | Select-Object -First 1
$sp = Get-MgServicePrincipal -Filter "appId eq '$($config.Graph.ClientId)'" -Property "id,appId,displayName" | Select-Object -First 1
if ($sp) {
Import-Module ExchangeOnlineManagement -Force -ErrorAction Stop
Connect-ExchangeOnline -ShowBanner:$false -ErrorAction Stop
try {
$shortId = $config.Graph.ClientId.Substring(0,8)
$scopeName = "SMTPGraphRelay-$shortId-Sender"
$assignmentName = "SMTPGraphRelay-$shortId-MailSend"
Remove-ManagementRoleAssignment -Identity $assignmentName -Confirm:$false -ErrorAction SilentlyContinue
Remove-ManagementScope -Identity $scopeName -Confirm:$false -ErrorAction SilentlyContinue
# Exchange Service Principal Referenz löschen, wenn Cmdlet verfügbar.
if (Get-Command Remove-ServicePrincipal -ErrorAction SilentlyContinue) {
Remove-ServicePrincipal -Identity $sp.Id -Confirm:$false -ErrorAction SilentlyContinue
}
Write-Ok "Exchange-RBAC-Objekte bereinigt."
}
finally {
Disconnect-ExchangeOnline -Confirm:$false -ErrorAction SilentlyContinue
}
Remove-MgServicePrincipal -ServicePrincipalId $sp.Id -ErrorAction SilentlyContinue
Write-Ok "Entra Service Principal entfernt."
}
if ($app) {
Remove-MgApplication -ApplicationId $app.Id -ErrorAction SilentlyContinue
Write-Ok "Entra App Registration entfernt."
}
}
finally {
Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null
}
}
}
# Programmdateien. Wenn der Installer selbst aus dem Zielordner läuft, kann er
# sich nicht zuverlässig selbst löschen. Dann bleiben Setup + Ordner bis nach Ende stehen.
$currentInstaller = [IO.Path]::GetFullPath($PSCommandPath)
$targetFull = [IO.Path]::GetFullPath($InstallPath)
foreach ($item in Get-ChildItem -LiteralPath $InstallPath -Force -ErrorAction SilentlyContinue) {
try {
if ($item.FullName -eq $currentInstaller) {
continue
}
Remove-Item -LiteralPath $item.FullName -Recurse -Force -ErrorAction Stop
}
catch {
Write-Warn "Konnte nicht entfernen: $($item.FullName)"
}
}
Write-Ok "Lokale Programmdateien entfernt."
if ($currentInstaller.StartsWith($targetFull, [StringComparison]::OrdinalIgnoreCase)) {
Write-Warn "Der aktuell laufende Installer bleibt übrig. Nach dem Beenden kann '$InstallPath' manuell gelöscht werden."
}
Write-Title "Deinstallation abgeschlossen"
}
function Show-Status {
Write-Title "SMTPGraphRelay - Status"
$configPath = Join-Path $InstallPath $ConfigFileName
Write-Host "Installationspfad: $InstallPath"
if (Test-Path -LiteralPath $configPath) {
Write-Ok "config.json vorhanden."
try {
$config = Get-RelayConfig -TargetPath $InstallPath
Write-Host " Sender: $($config.Graph.SenderMailbox)"
Write-Host " SMTP: $($config.Smtp.ListenAddress):$($config.Smtp.Port)"
Write-Host " Client: $($config.Graph.ClientId)"
if ($config.Smtp.PSObject.Properties.Name -contains "RequireAuth") {
$authText = if ([bool]$config.Smtp.RequireAuth) { "erforderlich" } else { "optional / aus" }
$authUsers = if ($config.Smtp.PSObject.Properties.Name -contains "AuthUsers") { @($config.Smtp.AuthUsers).Count } else { 0 }
Write-Host " AUTH: $authText ($authUsers Benutzer)"
}
try {
$failedCount = @(Get-FailedQueueEntries -Config $config).Count
Write-Host " Failed: $failedCount Mail(s)"
} catch {}
} catch {}
}
else {
Write-Warn "Keine config.json vorhanden."
}
$task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
if ($task) {
Write-Host "Task State: $($task.State)"
}
else {
Write-Warn "Scheduled Task nicht vorhanden."
}
}
function Show-Menu {
Clear-Host
Write-Title "SMTPGraphRelay - Bootstrap / Repair / Online Update"
Write-Host "Installationspfad: $InstallPath" -ForegroundColor DarkGray
$installedVersion = Get-InstalledVersion -TargetPath $InstallPath
if ($installedVersion -and $installedVersion.Version) {
Write-Host "Installiert: $($installedVersion.Version)" -ForegroundColor DarkGray
}
Write-Host "Updatequelle: Gitea / main" -ForegroundColor DarkGray
Write-Host ""
Write-Host " [1] Neuinstallation aus Gitea"
Write-Host " [2] Installation aus Gitea reparieren"
Write-Host " [3] Nach Online-Updates suchen"
Write-Host " [4] Entra / Exchange RBAC prüfen"
Write-Host " [5] Zertifikat erneuern"
Write-Host " [6] Health Check ausführen"
Write-Host " [7] Deinstallieren"
Write-Host " [8] Status anzeigen"
Write-Host " [9] SMTP-AUTH verwalten"
Write-Host " [10] Failed Queue verwalten"
Write-Host " [0] Beenden"
Write-Host ""
}
Assert-WindowsPowerShell51
while ($true) {
Show-Menu
$choice = Read-Host "Auswahl"
try {
switch ($choice) {
"1" { Install-New }
"2" { Repair-Installation }
"3" { Update-Relay }
"4" { Verify-CloudRbac }
"5" { Invoke-CertificateRenewal }
"6" { Invoke-HealthCheck }
"7" { Uninstall-Relay }
"8" { Show-Status }
"9" { Manage-SmtpAuth }
"10" { Manage-FailedQueue }
"0" { break }
default { Write-Warn "Ungültige Auswahl." }
}
}
catch {
Write-Host ""
Write-Fail $_.Exception.Message
if ($_.ScriptStackTrace) {
Write-Host $_.ScriptStackTrace -ForegroundColor DarkYellow
}
}
if ($choice -ne "0") {
Write-Host ""
Read-Host "Enter drücken, um zum Menü zurückzukehren"
}
if ($choice -eq "0") {
break
}
}