1177 lines
51 KiB
PowerShell
1177 lines
51 KiB
PowerShell
[CmdletBinding()]
|
|
param(
|
|
[string]$InstallPath = "$env:ProgramFiles\SMTPGraphRelay"
|
|
)
|
|
|
|
#Requires -Version 5.1
|
|
#Requires -RunAsAdministrator
|
|
<#
|
|
.SYNOPSIS
|
|
SMTPGraphRelay Installer / Repair / Update
|
|
|
|
.DESCRIPTION
|
|
Einheitliches Verwaltungswerkzeug fuer SMTPGraphRelay.
|
|
|
|
Modi:
|
|
1 - Neuinstallation
|
|
2 - Installation reparieren
|
|
3 - Relay aktualisieren
|
|
4 - Entra App & Berechtigungen pruefen
|
|
5 - Zertifikat erneuern
|
|
6 - Health Check ausfuehren
|
|
7 - Deinstallieren
|
|
|
|
WICHTIG:
|
|
Dieses Skript muss mit Windows PowerShell 5.1 ausgefuehrt werden.
|
|
#>
|
|
|
|
$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"
|
|
|
|
$ManagedReleaseFiles = @(
|
|
"SMTPGraphRelay.ps1",
|
|
"Test-SMTPGraphRelay.ps1",
|
|
"Test-SMTPGraphRelay-FailureModes.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 ausgefuehrt werden."
|
|
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 {
|
|
Ensure-PackageProvider
|
|
|
|
$modules = @(
|
|
"Microsoft.Graph.Authentication",
|
|
"Microsoft.Graph.Applications"
|
|
)
|
|
|
|
$allUsersPath = Join-Path ($env:ProgramFiles) "WindowsPowerShell\Modules"
|
|
|
|
foreach ($module in $modules) {
|
|
$existing = Get-Module -ListAvailable -Name ($module) |
|
|
Where-Object { $_.ModuleBase -like "$allUsersPath*" } |
|
|
Sort-Object Version -Descending |
|
|
Select-Object -First 1
|
|
|
|
if (-not $existing) {
|
|
Write-Info "$module fehlt (System-weit). Installation fuer AllUsers..."
|
|
Install-Module ($module) -Scope AllUsers -Repository PSGallery -Force -AllowClobber
|
|
|
|
$existing = Get-Module -ListAvailable -Name ($module) |
|
|
Where-Object { $_.ModuleBase -like "$allUsersPath*" } |
|
|
Sort-Object Version -Descending |
|
|
Select-Object -First 1
|
|
}
|
|
|
|
if (-not $existing) {
|
|
throw "Modul '$module' konnte nicht fuer AllUsers installiert/gefunden werden."
|
|
}
|
|
Write-Ok "$module verfuegbar (System-weit)."
|
|
}
|
|
}
|
|
|
|
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..."
|
|
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)
|
|
|
|
foreach ($required in @("SMTPGraphRelay.ps1", "version.json")) {
|
|
if (-not (Test-Path -LiteralPath (Join-Path ($releaseRoot) $required))) {
|
|
throw "Repository-Archiv ist unvollstaendig: '$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 enthaelt 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-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 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" -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
|
|
|
|
$scopes = @("Application.ReadWrite.All", "AppRoleAssignment.ReadWrite.All")
|
|
if ([string]::IsNullOrWhiteSpace($TenantId)) {
|
|
Connect-MgGraph -Scopes ($scopes) -NoWelcome
|
|
} else {
|
|
Connect-MgGraph -TenantId ($TenantId) -Scopes ($scopes) -NoWelcome
|
|
}
|
|
}
|
|
|
|
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 enthaelt 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)
|
|
$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 gueltiges 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 unvollstaendig: '$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 Nachpruefung entfaellt."
|
|
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."
|
|
if (-not (Confirm-Yes "Trotzdem fortfahren?")) { return }
|
|
}
|
|
|
|
$staging = $null
|
|
try {
|
|
$staging = New-RepoStagingArea
|
|
Ensure-Modules
|
|
Ensure-Directories -TargetPath ($InstallPath)
|
|
Install-RepoProgramFiles -ReleaseRoot ($staging.ReleaseRoot) -TargetPath ($InstallPath)
|
|
Write-Ok "Programmdateien aus Gitea installiert."
|
|
|
|
$appName = Read-Default "Name der Entra App" $AppDefaultName
|
|
$senderMailbox = Read-Host "Standard-Absenderpostfach (Fallback/Test, z.B. info@firma.de)"
|
|
while ([string]::IsNullOrWhiteSpace($senderMailbox) -or $senderMailbox -notmatch '^[^@\s]+@[^@\s]+\.[^@\s]+$') {
|
|
$senderMailbox = Read-Host "Bitte eine gueltige Mailadresse eingeben"
|
|
}
|
|
|
|
# NEU: Abfrage für Support-Alarmadresse
|
|
$alertMailbox = Read-Default "Support-Mailadresse fuer Zertifikats-Alarme (leer = keine Alarme)" ""
|
|
|
|
$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)"
|
|
|
|
Write-Info "Erteile globale 'Mail.Send' Application Berechtigung (Tenant-weiter Versand)..."
|
|
$graphSp = Get-MgServicePrincipal -Filter "appId eq '00000003-0000-0000-c000-000000000000'" -Property "id,appRoles"
|
|
$mailSendRole = $graphSp.AppRoles | Where-Object { $_.Value -eq "Mail.Send" -and $_.AllowedMemberTypes -contains "Application" } | Select-Object -First 1
|
|
|
|
if (-not $mailSendRole) { throw "Microsoft Graph AppRole 'Mail.Send' konnte nicht gefunden werden." }
|
|
|
|
$existingAssig = Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId ($sp.Id) -All -ErrorAction SilentlyContinue | Where-Object { $_.AppRoleId -eq $mailSendRole.Id }
|
|
|
|
if (-not $existingAssig) {
|
|
New-MgServicePrincipalAppRoleAssignment -ServicePrincipalId ($sp.Id) -PrincipalId ($sp.Id) -ResourceId ($graphSp.Id) -AppRoleId ($mailSendRole.Id) | Out-Null
|
|
Write-Ok "Globale 'Mail.Send' Application Role erfolgreich vergeben."
|
|
} else {
|
|
Write-Ok "Globale 'Mail.Send' Berechtigung war bereits vergeben."
|
|
}
|
|
|
|
$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
|
|
AlertMailbox = $alertMailbox
|
|
ForceSender = $false
|
|
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 bestaetigt 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 abschliessenden Health Check..."
|
|
& $health -ConfigPath (Join-Path ($InstallPath) $ConfigFileName)
|
|
if ($LASTEXITCODE -ge 2) { Write-Warn "Health Check meldet Fehler. Bitte Ausgabe pruefen." }
|
|
}
|
|
|
|
Write-Title "Neuinstallation abgeschlossen"
|
|
Write-Host "Installationspfad: $InstallPath"
|
|
}
|
|
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 fuer bestehende Installationen gedacht."
|
|
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." }
|
|
elseif (-not $cert.HasPrivateKey) { Write-Fail "Konfiguriertes Zertifikat besitzt keinen privaten Schluessel." }
|
|
else { Write-Ok "Zertifikat vorhanden." }
|
|
|
|
$backupPath = Backup-ManagedFiles -TargetPath ($InstallPath)
|
|
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." }
|
|
Write-Title "Repair abgeschlossen"
|
|
}
|
|
catch {
|
|
Write-Fail "Repair fehlgeschlagen: $($_.Exception.Message)"
|
|
if ($backupPath -and (Test-Path -LiteralPath ($backupPath))) {
|
|
try {
|
|
Stop-RelayTask
|
|
Restore-ManagedFiles -BackupPath ($backupPath) -TargetPath ($InstallPath)
|
|
Ensure-ScheduledTask -TargetPath ($InstallPath)
|
|
Start-RelayTask
|
|
} catch {}
|
|
}
|
|
}
|
|
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."
|
|
return
|
|
}
|
|
|
|
$installedVersionInfo = Get-InstalledVersion -TargetPath ($InstallPath)
|
|
$installedVersion = if ($installedVersionInfo) { [string]$installedVersionInfo.Version } else { Read-Host "Installierte Version manuell eingeben" }
|
|
if ([string]::IsNullOrWhiteSpace($installedVersion)) { return }
|
|
|
|
try { $remoteInfo = Get-RemoteVersion } catch { Write-Fail "Remote-Version konnte nicht geladen werden."; return }
|
|
$remoteVersion = [string]$remoteInfo.Version
|
|
|
|
$comparison = Compare-RelayVersions -Installed ($installedVersion) -Remote ($remoteVersion)
|
|
if ($comparison -eq 0) { Write-Ok "Bereits aktuell ($installedVersion)."; return }
|
|
if (-not (Confirm-Yes "Update von $installedVersion auf $remoteVersion durchfuehren?")) { return }
|
|
|
|
$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
|
|
try {
|
|
Invoke-WebRequest -Uri ($RemoteArchiveUrl) -OutFile ($archivePath) -UseBasicParsing -TimeoutSec 120 -ErrorAction Stop
|
|
Expand-Archive -LiteralPath ($archivePath) -DestinationPath ($extractPath) -Force
|
|
$releaseRoot = Find-ExtractedReleaseRoot -ExtractPath ($extractPath)
|
|
|
|
$backupPath = Backup-ManagedFiles -TargetPath ($InstallPath)
|
|
Stop-RelayTask
|
|
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." }
|
|
Write-Title "Online Update abgeschlossen"
|
|
}
|
|
catch {
|
|
Write-Fail "Update fehlgeschlagen: $($_.Exception.Message)"
|
|
if ($backupPath -and (Test-Path -LiteralPath ($backupPath))) {
|
|
try {
|
|
Stop-RelayTask
|
|
Restore-ManagedFiles -BackupPath ($backupPath) -TargetPath ($InstallPath)
|
|
Ensure-ScheduledTask -TargetPath ($InstallPath)
|
|
Start-RelayTask
|
|
} catch {}
|
|
}
|
|
}
|
|
finally {
|
|
Remove-Item -LiteralPath ($updateRoot) -Recurse -Force -ErrorAction SilentlyContinue
|
|
}
|
|
}
|
|
|
|
function Verify-CloudPermissions {
|
|
Write-Title "SMTPGraphRelay - Entra Berechtigungen pruefen"
|
|
$config = Get-RelayConfig -TargetPath ($InstallPath)
|
|
if (-not $config) { return }
|
|
|
|
Ensure-Modules
|
|
Connect-RelayGraphAdmin -TenantId ($config.Graph.TenantId)
|
|
|
|
try {
|
|
$app = Get-MgApplication -Filter "appId eq '$($config.Graph.ClientId)'" -Property "id,appId,displayName" | Select-Object -First 1
|
|
if (-not $app) { Write-Fail "App Registration nicht gefunden."; return }
|
|
|
|
$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 }
|
|
|
|
$graphSp = Get-MgServicePrincipal -Filter "appId eq '00000003-0000-0000-c000-000000000000'" -Property "id,appRoles"
|
|
$mailSendRole = $graphSp.AppRoles | Where-Object { $_.Value -eq "Mail.Send" -and $_.AllowedMemberTypes -contains "Application" } | Select-Object -First 1
|
|
|
|
$globalAssig = Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId ($sp.Id) -All -ErrorAction SilentlyContinue | Where-Object { $_.AppRoleId -eq $mailSendRole.Id }
|
|
|
|
if ($globalAssig) { Write-Ok "GLOBALE (tenant-weite) Mail.Send Berechtigung vorhanden." }
|
|
else { Write-Fail "GLOBALE Mail.Send Berechtigung fehlt!" }
|
|
}
|
|
finally {
|
|
Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null
|
|
}
|
|
}
|
|
|
|
function Invoke-CertificateRenewal {
|
|
$renew = Join-Path ($InstallPath) $RenewFileName
|
|
if (-not (Test-Path -LiteralPath ($renew))) { return }
|
|
& $renew -ConfigPath (Join-Path ($InstallPath) $ConfigFileName)
|
|
}
|
|
|
|
function Invoke-HealthCheck {
|
|
$health = Join-Path ($InstallPath) $HealthFileName
|
|
if (-not (Test-Path -LiteralPath ($health))) { return }
|
|
& $health -ConfigPath (Join-Path ($InstallPath) $ConfigFileName)
|
|
}
|
|
|
|
function Invoke-Pbkdf2Sha256 {
|
|
param([Parameter(Mandatory)][string]$Password, [Parameter(Mandatory)][byte[]]$Salt, [Parameter(Mandatory)][int]$Iterations, [int]$Length = 32)
|
|
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 {
|
|
$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)][string]$Password)
|
|
$salt = New-Object byte[] 16
|
|
$rng = [Security.Cryptography.RandomNumberGenerator]::Create()
|
|
try { $rng.GetBytes($salt) } finally { $rng.Dispose() }
|
|
|
|
$iterations = 150000
|
|
$hash = Invoke-Pbkdf2Sha256 -Password ($Password) -Salt ($salt) -Iterations ($iterations) -Length 32
|
|
return [pscustomobject]@{ Salt = [Convert]::ToBase64String($salt); PasswordHash = [Convert]::ToBase64String($hash); Iterations = $iterations }
|
|
}
|
|
|
|
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 enthaelt 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 " [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 endgueltig loeschen"
|
|
Write-Host " [6] Alle Failed-Mails endgueltig loeschen"
|
|
Write-Host " [0] Zurueck`n"
|
|
|
|
$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 "`nFehler: $($entry.LastError)" -ForegroundColor Yellow }
|
|
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 ($entry) { Retry-FailedQueueEntry -Config ($config) -Entry ($entry); Write-Ok "[$($entry.QueueId)] zurueck nach pending verschoben." }
|
|
Read-Host "Enter"
|
|
}
|
|
"4" {
|
|
if (Confirm-Yes "Alle Mails erneut zustellen?") {
|
|
foreach ($entry in $entries) { try { Retry-FailedQueueEntry -Config ($config) -Entry ($entry) } catch {} }
|
|
}
|
|
}
|
|
"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 ($entry -and (Confirm-Yes "Loeschen?")) { Remove-FailedQueueEntry -Entry ($entry); Write-Ok "Geloescht." }
|
|
Read-Host "Enter"
|
|
}
|
|
"6" {
|
|
if (Confirm-Yes "Alle loeschen?") { foreach ($entry in $entries) { Remove-FailedQueueEntry -Entry ($entry) } }
|
|
}
|
|
"0" { return }
|
|
}
|
|
$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 hinzufuegen";
|
|
Write-Host " [3] Benutzer anzeigen";
|
|
Write-Host " [4] Passwort aendern";
|
|
Write-Host " [5] Benutzer loeschen";
|
|
Write-Host " [6] Netze ohne Auth verwalten";
|
|
Write-Host " [7] Max. Fehlversuche aendern";
|
|
Write-Host " [0] Zurueck`n";
|
|
|
|
$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 "Status geaendert."; Read-Host "Enter";
|
|
}
|
|
"2" {
|
|
$username = Read-Host "Benutzername";
|
|
if ([string]::IsNullOrWhiteSpace($username) -or $username -notmatch '^[A-Za-z0-9._@-]{1,128}$') { Write-Fail "Ungueltiger 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 existiert bereits."; Read-Host "Enter"; continue; }
|
|
|
|
$plain1 = Read-Host "Passwort";
|
|
$plain2 = Read-Host "Passwort wiederholen";
|
|
if ($plain1.Length -lt 8 -or $plain1 -cne$plain2) { Write-Fail "Passwort ungueltig (mind. 8 Zeichen oder stimmen nicht ueberein)."; Read-Host "Enter"; continue; }
|
|
|
|
$record = New-SmtpPasswordRecord -Password ($plain1);
|
|
$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 "Angelegt."; Read-Host "Enter";
|
|
}
|
|
"3" {
|
|
if (@($config.Smtp.AuthUsers).Count -eq 0) { Write-Warn "Keine SMTP-Benutzer vorhanden."; } else { @($config.Smtp.AuthUsers) | Format-Table Username,Iterations -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 nicht gefunden."; Read-Host "Enter"; continue; }
|
|
|
|
$plain1 = Read-Host "Neues Passwort";
|
|
$plain2 = Read-Host "Passwort wiederholen";
|
|
if ($plain1.Length -lt 8 -or $plain1 -cne$plain2) { Write-Fail "Passwort ungueltig (mind. 8 Zeichen oder stimmen nicht ueberein)."; Read-Host "Enter"; continue; }
|
|
|
|
$record = New-SmtpPasswordRecord -Password ($plain1);
|
|
$user.Salt =$record.Salt; $user.PasswordHash =$record.PasswordHash; $user.Iterations =$record.Iterations;
|
|
Save-SmtpAuthConfigAndRestart -Config ($config);
|
|
Write-Ok "Geaendert."; 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 nicht gefunden."; Read-Host "Enter"; continue; }
|
|
if (Confirm-Yes "Loeschen?") {
|
|
$config.Smtp.AuthUsers = @($config.Smtp.AuthUsers | Where-Object { -not ([string]$_.Username).Equals($username, [StringComparison]::OrdinalIgnoreCase) });
|
|
Save-SmtpAuthConfigAndRestart -Config ($config);
|
|
Write-Ok "Geloescht.";
|
|
}
|
|
Read-Host "Enter";
|
|
}
|
|
"6" {
|
|
$current = @($config.Smtp.AllowUnauthenticatedNetworks) -join ",";
|
|
$input = Read-Default "Netze/IPs ohne AUTH" $(if ($current) {$current } else { "-" });
|
|
if ($input -eq "-") { $config.Smtp.AllowUnauthenticatedNetworks = @(); } else { $config.Smtp.AllowUnauthenticatedNetworks = @($input -split "," | ForEach-Object { $_.Trim() } \vert{} Where-Object {$_ }); }
|
|
Save-SmtpAuthConfigAndRestart -Config ($config);
|
|
}
|
|
"7" {
|
|
$value = Read-Host "Max. Fehlversuche";
|
|
if ($value -match '^\d+$') {$config.Smtp.AuthMaxFailures = [int]$value; Save-SmtpAuthConfigAndRestart -Config ($config); }
|
|
}
|
|
"0" { return; }
|
|
}
|
|
$config = Get-RelayConfig -TargetPath ($InstallPath);
|
|
$config = Ensure-SmtpAuthConfig -Config ($config);
|
|
}
|
|
}
|
|
|
|
function Invoke-FailureModeTests {
|
|
$tester = Join-Path ($InstallPath) "Test-SMTPGraphRelay-FailureModes.ps1"
|
|
if (Test-Path -LiteralPath ($tester)) { & $tester -InstallPath ($InstallPath) }
|
|
}
|
|
|
|
function Uninstall-Relay {
|
|
Write-Title "SMTPGraphRelay - Deinstallation"
|
|
$config = Get-RelayConfig -TargetPath ($InstallPath)
|
|
if (-not (Confirm-Yes "Wirklich entfernen?")) { return }
|
|
|
|
Stop-RelayTask
|
|
Unregister-ScheduledTask -TaskName ($TaskName) -Confirm:$false -ErrorAction SilentlyContinue
|
|
Get-NetFirewallRule -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName -like "SMTPGraphRelay TCP *" } | Remove-NetFirewallRule -ErrorAction SilentlyContinue
|
|
|
|
if ($config -and$config.Graph.CertificateThumbprint -and (Confirm-Yes "Zertifikat entfernen?")) {
|
|
Remove-Item -LiteralPath "Cert:\LocalMachine\My\$($config.Graph.CertificateThumbprint)" -Force -ErrorAction SilentlyContinue
|
|
}
|
|
if ($config -and (Confirm-Yes "Entra-App entfernen?")) {
|
|
Ensure-Modules
|
|
Connect-RelayGraphAdmin -TenantId ($config.Graph.TenantId)
|
|
try {
|
|
$app = Get-MgApplication -Filter "appId eq '$($config.Graph.ClientId)'" -Property "id" | Select-Object -First 1
|
|
$sp = Get-MgServicePrincipal -Filter "appId eq '$($config.Graph.ClientId)'" -Property "id" | Select-Object -First 1
|
|
if ($sp) { Remove-MgServicePrincipal -ServicePrincipalId ($sp.Id) -ErrorAction SilentlyContinue }
|
|
if ($app) { Remove-MgApplication -ApplicationId ($app.Id) -ErrorAction SilentlyContinue }
|
|
} finally { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null }
|
|
}
|
|
|
|
$currentInstaller = [IO.Path]::GetFullPath($PSCommandPath)
|
|
foreach ($item in Get-ChildItem -LiteralPath ($InstallPath) -Force -ErrorAction SilentlyContinue) {
|
|
if ($item.FullName -ne $currentInstaller) { Remove-Item -LiteralPath ($item.FullName) -Recurse -Force -ErrorAction SilentlyContinue }
|
|
}
|
|
Write-Ok "Programmdateien entfernt."
|
|
}
|
|
|
|
function Show-Status {
|
|
Write-Title "SMTPGraphRelay - Status anzeigen"
|
|
$configPath = Join-Path ($InstallPath)$ConfigFileName
|
|
Write-Host "Installationspfad: $InstallPath"
|
|
|
|
if (Test-Path -LiteralPath ($configPath)) {
|
|
try {
|
|
$config = Get-RelayConfig -TargetPath ($InstallPath)
|
|
Write-Host " Sender: $($config.Graph.SenderMailbox)"
|
|
Write-Host " Alert: $($config.Graph.AlertMailbox)"
|
|
Write-Host " SMTP: $($config.Smtp.ListenAddress):$($config.Smtp.Port)"
|
|
Write-Host " AUTH: $([bool]$config.Smtp.RequireAuth)"
|
|
Write-Host " Failed: $(@(Get-FailedQueueEntries -Config ($config)).Count) Mail(s)"
|
|
} catch {}
|
|
}
|
|
$task = Get-ScheduledTask -TaskName ($TaskName) -ErrorAction SilentlyContinue
|
|
if ($task) { Write-Host "Task State: $($task.State)" }
|
|
}
|
|
|
|
function Show-Menu {
|
|
Clear-Host
|
|
Write-Title "SMTPGraphRelay - Verwaltung"
|
|
|
|
# --- HIER: Installationspfad & Version anzeigen ---
|
|
Write-Host "Installationspfad: $InstallPath" -ForegroundColor DarkGray
|
|
|
|
$installedVerInfo = Get-InstalledVersion -TargetPath ($InstallPath)
|
|
if ($installedVerInfo) {
|
|
Write-Host "Installierte Version: $($installedVerInfo.Version)" -ForegroundColor DarkGray
|
|
} else {
|
|
Write-Host "Installierte Version: (Nicht installiert / Unbekannt)" -ForegroundColor DarkGray
|
|
}
|
|
Write-Host "----------------------------------------------------------" -ForegroundColor DarkGray
|
|
# -----------------------------------------------------------------
|
|
|
|
Write-Host " [1] Neuinstallation aus Gitea"
|
|
Write-Host " [2] Installation reparieren"
|
|
Write-Host " [3] Online Update"
|
|
Write-Host " [4] Entra Berechtigungen pruefen"
|
|
Write-Host " [5] Zertifikat erneuern"
|
|
Write-Host " [6] Health Check"
|
|
Write-Host " [7] Deinstallieren"
|
|
Write-Host " [8] Status anzeigen"
|
|
Write-Host " [9] SMTP-AUTH verwalten"
|
|
Write-Host " [10] Failed Queue"
|
|
Write-Host " [0] Beenden`n"
|
|
}
|
|
|
|
Assert-WindowsPowerShell51
|
|
while ($true) {
|
|
Show-Menu
|
|
$choice = Read-Host "Auswahl"
|
|
try {
|
|
switch ($choice) {
|
|
"1" { Install-New } "2" { Repair-Installation } "3" { Update-Relay } "4" { Verify-CloudPermissions } "5" { Invoke-CertificateRenewal }
|
|
"6" { Invoke-HealthCheck } "7" { Uninstall-Relay } "8" { Show-Status } "9" { Manage-SmtpAuth } "10" { Manage-FailedQueue } "0" { break }
|
|
}
|
|
} catch { Write-Fail $_.Exception.Message }
|
|
if ($choice -ne "0") { Read-Host "`nEnter druecken..." }
|
|
if ($choice -eq "0") { break }
|
|
} |