1401 lines
45 KiB
PowerShell
1401 lines
45 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 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"
|
|
|
|
$action = New-ScheduledTaskAction `
|
|
-Execute $psExe `
|
|
-Argument "-NoLogo -NoProfile -ExecutionPolicy Bypass -File `"$scriptPath`"" `
|
|
-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 ($task -and $task.State -eq "Running") {
|
|
Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
|
Start-Sleep -Milliseconds 750
|
|
}
|
|
}
|
|
|
|
function Start-RelayTask {
|
|
$task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
|
if ($task) {
|
|
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"
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
Ensure-Modules -IncludeExchange
|
|
Copy-ProgramFiles -TargetPath $InstallPath -RequireRelay
|
|
Ensure-Directories -TargetPath $InstallPath
|
|
|
|
$packageVersion = Get-PackageVersion
|
|
if ($packageVersion -and $packageVersion.Version) {
|
|
Write-Ok "Installiere Paketversion $($packageVersion.Version)."
|
|
}
|
|
|
|
$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)"
|
|
|
|
# Keine globale Graph Mail.Send Permission!
|
|
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
|
|
}
|
|
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)
|
|
}
|
|
Paths = [ordered]@{
|
|
Queue = "queue"
|
|
Failed = "failed"
|
|
Logs = "logs"
|
|
}
|
|
Logging = [ordered]@{
|
|
MaxFileSizeMB = 10
|
|
RetentionDays = 30
|
|
CleanupHours = 12
|
|
}
|
|
}
|
|
|
|
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
|
|
|
|
Write-Title "Neuinstallation abgeschlossen"
|
|
Write-Host "Installationspfad: $InstallPath"
|
|
|
|
$packageVersion = Get-PackageVersion
|
|
$installedVersion = Get-InstalledVersion -TargetPath $InstallPath
|
|
|
|
if ($packageVersion -and $packageVersion.Version) {
|
|
Write-Host "Paketversion: $($packageVersion.Version)"
|
|
}
|
|
|
|
if ($installedVersion -and $installedVersion.Version) {
|
|
Write-Host "Installierte Version: $($installedVersion.Version)"
|
|
}
|
|
elseif (Test-Path -LiteralPath (Join-Path $InstallPath $RelayFileName)) {
|
|
Write-Warn "Installierte Version unbekannt (keine version.json)."
|
|
}
|
|
|
|
try {
|
|
$remoteVersionInfo = Get-RemoteVersion
|
|
if ($remoteVersionInfo -and $remoteVersionInfo.Version) {
|
|
Write-Host "Remote (main): $($remoteVersionInfo.Version)"
|
|
|
|
if ($installedVersion -and $installedVersion.Version) {
|
|
$cmp = Compare-RelayVersions `
|
|
-Installed ([string]$installedVersion.Version) `
|
|
-Remote ([string]$remoteVersionInfo.Version)
|
|
|
|
if ($cmp -gt 0) {
|
|
Write-Warn "Update verfügbar."
|
|
}
|
|
elseif ($cmp -eq 0) {
|
|
Write-Ok "Version ist aktuell."
|
|
}
|
|
else {
|
|
Write-Warn "Lokale Version ist neuer als Repository-main."
|
|
}
|
|
}
|
|
}
|
|
}
|
|
catch {
|
|
Write-Warn "Remote-Version konnte nicht geprüft werden."
|
|
}
|
|
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
|
|
}
|
|
}
|
|
|
|
function Repair-Installation {
|
|
Write-Title "SMTPGraphRelay - Repair"
|
|
|
|
$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
|
|
}
|
|
|
|
Ensure-Modules
|
|
Copy-ProgramFiles -TargetPath $InstallPath
|
|
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."
|
|
}
|
|
|
|
Ensure-FirewallRule -Port ([int]$config.Smtp.Port)
|
|
Ensure-ScheduledTask -TargetPath $InstallPath
|
|
|
|
Start-RelayTask
|
|
|
|
$health = Join-Path $InstallPath $HealthFileName
|
|
if (Test-Path -LiteralPath $health) {
|
|
Write-Info "Starte Health Check..."
|
|
& $health -ConfigPath (Join-Path $InstallPath $ConfigFileName)
|
|
$healthExit = $LASTEXITCODE
|
|
Write-Info "Health Check ExitCode: $healthExit"
|
|
}
|
|
|
|
Write-Title "Repair abgeschlossen"
|
|
}
|
|
|
|
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 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)"
|
|
} 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 - Installer / Repair / 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"
|
|
Write-Host " [2] Installation 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 " [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 }
|
|
"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
|
|
}
|
|
}
|