From b13442830bfeb45ed40a9e3aeb64524079d5b940 Mon Sep 17 00:00:00 2001 From: "manuel.maier" Date: Thu, 13 Aug 2026 23:40:25 +0200 Subject: [PATCH] Dateien nach "/" hochladen --- Setup-SMTPGraphRelay.ps1 | 503 ++++++++++++++++++++++++--------------- 1 file changed, 308 insertions(+), 195 deletions(-) diff --git a/Setup-SMTPGraphRelay.ps1 b/Setup-SMTPGraphRelay.ps1 index 0d3b45a..0246f5a 100644 --- a/Setup-SMTPGraphRelay.ps1 +++ b/Setup-SMTPGraphRelay.ps1 @@ -151,6 +151,92 @@ function Ensure-Modules { } } + +function New-RepoStagingArea { + Enable-Tls12 + + $root = Join-Path $env:TEMP ("SMTPGraphRelay-repo-{0}" -f [guid]::NewGuid().ToString("N")) + $archive = Join-Path $root "main.zip" + $extract = Join-Path $root "extract" + + New-Item -ItemType Directory -Path $extract -Force | Out-Null + + Write-Info "Lade aktuellen Stand aus Gitea..." + Write-Info "Quelle: $RemoteArchiveUrl" + + Invoke-WebRequest ` + -Uri $RemoteArchiveUrl ` + -OutFile $archive ` + -UseBasicParsing ` + -TimeoutSec 120 ` + -ErrorAction Stop + + if (-not (Test-Path -LiteralPath $archive)) { + throw "Gitea-Archiv wurde nicht heruntergeladen." + } + + Expand-Archive ` + -LiteralPath $archive ` + -DestinationPath $extract ` + -Force + + $releaseRoot = Find-ExtractedReleaseRoot -ExtractPath $extract + + # Pflichtdateien prüfen. + foreach ($required in @("SMTPGraphRelay.ps1", "version.json")) { + if (-not (Test-Path -LiteralPath (Join-Path $releaseRoot $required))) { + throw "Repository-Archiv ist unvollständig: '$required' fehlt." + } + } + + $versionInfo = Get-Content ` + -LiteralPath (Join-Path $releaseRoot "version.json") ` + -Raw ` + -Encoding UTF8 | ConvertFrom-Json + + if (-not $versionInfo.Version) { + throw "version.json aus dem Repository enthält keine Version." + } + + Write-Ok "Repository-Version $($versionInfo.Version) geladen." + + return [pscustomobject]@{ + TempRoot = $root + ReleaseRoot = $releaseRoot + VersionInfo = $versionInfo + } +} + +function Remove-RepoStagingArea { + param($Staging) + + if ($Staging -and $Staging.TempRoot) { + Remove-Item -LiteralPath $Staging.TempRoot -Recurse -Force -ErrorAction SilentlyContinue + } +} + +function Install-RepoProgramFiles { + param( + [Parameter(Mandatory)][string]$ReleaseRoot, + [Parameter(Mandatory)][string]$TargetPath + ) + + New-Item -ItemType Directory -Path $TargetPath -Force | Out-Null + + foreach ($name in $ManagedReleaseFiles) { + $source = Join-Path $ReleaseRoot $name + + if (Test-Path -LiteralPath $source) { + Copy-Item ` + -LiteralPath $source ` + -Destination (Join-Path $TargetPath $name) ` + -Force + + Write-Ok "Installiert: $name" + } + } +} + function Get-SourceFile { param([Parameter(Mandatory)][string]$Name) @@ -691,200 +777,185 @@ function Invoke-PostUpdateHealthCheck { } function Install-New { - Write-Title "SMTPGraphRelay - Neuinstallation" + Write-Title "SMTPGraphRelay - Neuinstallation aus Gitea" if (Test-Path -LiteralPath (Join-Path $InstallPath $ConfigFileName)) { Write-Warn "Es existiert bereits eine config.json unter:" Write-Host " $InstallPath" Write-Warn "Neuinstallation würde eine neue App/Zertifikat erzeugen." + if (-not (Confirm-Yes "Trotzdem fortfahren?")) { return } } - 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 + $staging = $null try { - $tenantId = (Get-MgContext).TenantId - Write-Ok "Tenant: $tenantId" + $staging = New-RepoStagingArea - $appParams = @{ - DisplayName = $appName - SignInAudience = "AzureADMyOrg" - KeyCredentials = @( - (Convert-CertToKeyCredential -Certificate $cert) - ) + Ensure-Modules -IncludeExchange + Ensure-Directories -TargetPath $InstallPath + + # Nur Programmdateien aus Git übernehmen. + Install-RepoProgramFiles ` + -ReleaseRoot $staging.ReleaseRoot ` + -TargetPath $InstallPath + + Write-Ok "Programmdateien aus Gitea installiert." + + $appName = Read-Default "Name der Entra App" $AppDefaultName + + $senderMailbox = Read-Host "M365-Absenderpostfach (z.B. info@firma.de)" + while ([string]::IsNullOrWhiteSpace($senderMailbox) -or $senderMailbox -notmatch '^[^@\s]+@[^@\s]+\.[^@\s]+$') { + $senderMailbox = Read-Host "Bitte eine gültige Mailadresse eingeben" } - $app = New-MgApplication -BodyParameter $appParams - Write-Ok "App Registration erstellt: $($app.AppId)" + $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 { $_ }) - $sp = $null - for ($i = 0; $i -lt 10 -and -not $sp; $i++) { - try { - $sp = New-MgServicePrincipal -AppId $app.AppId + 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) + ) } - catch { - Start-Sleep -Seconds 2 - } - } - if (-not $sp) { - throw "Entra Service Principal konnte nicht erstellt werden." - } + $app = New-MgApplication -BodyParameter $appParams + Write-Ok "App Registration erstellt: $($app.AppId)" - 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." + $sp = $null + for ($i = 0; $i -lt 10 -and -not $sp; $i++) { + try { + $sp = New-MgServicePrincipal -AppId $app.AppId } - elseif ($cmp -eq 0) { - Write-Ok "Version ist aktuell." - } - else { - Write-Warn "Lokale Version ist neuer als Repository-main." + catch { + Start-Sleep -Seconds 2 } } + + if (-not $sp) { + throw "Entra Service Principal konnte nicht erstellt werden." + } + + Write-Ok "Entra Service Principal erstellt: $($sp.Id)" + Test-NoGlobalMailSend -ServicePrincipalObjectId $sp.Id + + Ensure-ExchangeRbac ` + -TenantId $tenantId ` + -ClientId $app.AppId ` + -ServicePrincipalObjectId $sp.Id ` + -AppName $appName ` + -SenderMailbox $senderMailbox + + $config = [ordered]@{ + Smtp = [ordered]@{ + ListenAddress = $listenAddress + Port = $port + Hostname = $env:COMPUTERNAME + AllowedNetworks = $allowedNetworks + MaxMessageSizeMB = 25 + ClientTimeoutSeconds = 120 + MaxConcurrentClients = 20 + } + 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 + } + Update = [ordered]@{ + Repository = $RepoBaseUrl + Branch = "main" + } + } + + Write-RelayConfig -Config $config -TargetPath $InstallPath + Ensure-FirewallRule -Port $port + Ensure-ScheduledTask -TargetPath $InstallPath + + Write-Info "Teste App-only Anmeldung mit Relay-Zertifikat..." + Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null + + Connect-MgGraph ` + -TenantId $tenantId ` + -ClientId $app.AppId ` + -Certificate $cert ` + -NoWelcome | Out-Null + + $ctx = Get-MgContext + if (-not $ctx -or $ctx.AuthType -ne "AppOnly") { + throw "App-only Anmeldung konnte nicht bestätigt werden." + } + + Write-Ok "App-only Anmeldung funktioniert." + Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null + + Start-RelayTask + + $health = Join-Path $InstallPath $HealthFileName + if (Test-Path -LiteralPath $health) { + Write-Info "Starte abschließenden Health Check..." + & $health -ConfigPath (Join-Path $InstallPath $ConfigFileName) + $healthExit = $LASTEXITCODE + + if ($healthExit -ge 2) { + Write-Warn "Installation abgeschlossen, Health Check meldet Fehler. Bitte Ausgabe prüfen." + } + } + + Write-Title "Neuinstallation abgeschlossen" + Write-Host "Version: $($staging.VersionInfo.Version)" + Write-Host "Installationspfad: $InstallPath" + Write-Host "Client ID: $($app.AppId)" + Write-Host "Tenant ID: $tenantId" + Write-Host "Sender: $senderMailbox" + Write-Host "SMTP: $listenAddress`:$port" + } + finally { + Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null } - } - 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 + Remove-RepoStagingArea -Staging $staging } } function Repair-Installation { - Write-Title "SMTPGraphRelay - Repair" + Write-Title "SMTPGraphRelay - Repair aus Gitea" $config = Get-RelayConfig -TargetPath $InstallPath if (-not $config) { @@ -893,38 +964,80 @@ function Repair-Installation { return } - Ensure-Modules - Copy-ProgramFiles -TargetPath $InstallPath - Ensure-Directories -TargetPath $InstallPath + $staging = $null + $backupPath = $null - $certPath = "Cert:\LocalMachine\My\$($config.Graph.CertificateThumbprint)" - $cert = Get-Item -LiteralPath $certPath -ErrorAction SilentlyContinue + try { + $staging = New-RepoStagingArea - if (-not $cert) { - Write-Fail "Konfiguriertes Zertifikat fehlt: $($config.Graph.CertificateThumbprint)" - Write-Warn "Repair erzeugt absichtlich kein neues Credential. Nutze Zertifikat erneuern." + Ensure-Modules + Ensure-Directories -TargetPath $InstallPath + + $certPath = "Cert:\LocalMachine\My\$($config.Graph.CertificateThumbprint)" + $cert = Get-Item -LiteralPath $certPath -ErrorAction SilentlyContinue + + if (-not $cert) { + Write-Fail "Konfiguriertes Zertifikat fehlt: $($config.Graph.CertificateThumbprint)" + Write-Warn "Repair erzeugt absichtlich kein neues Credential. Nutze Zertifikat erneuern." + } + elseif (-not $cert.HasPrivateKey) { + Write-Fail "Konfiguriertes Zertifikat besitzt keinen privaten Schlüssel." + } + else { + Write-Ok "Zertifikat vorhanden und besitzt Private Key." + } + + Write-Info "Sichere aktuelle Programmdateien..." + $backupPath = Backup-ManagedFiles -TargetPath $InstallPath + Write-Ok "Backup: $backupPath" + + Stop-RelayTask + + Install-RepoProgramFiles ` + -ReleaseRoot $staging.ReleaseRoot ` + -TargetPath $InstallPath + + Ensure-FirewallRule -Port ([int]$config.Smtp.Port) + Ensure-ScheduledTask -TargetPath $InstallPath + Start-RelayTask + + $healthExit = Invoke-PostUpdateHealthCheck -TargetPath $InstallPath + + if ($healthExit -ge 2) { + throw "Health Check nach Repair meldet FEHLER (ExitCode $healthExit)." + } + + if ($healthExit -eq 1) { + Write-Warn "Repair abgeschlossen, Health Check enthält Warnungen." + } + else { + Write-Ok "Repair Health Check erfolgreich." + } + + Write-Title "Repair abgeschlossen" + Write-Host "Installierte Repo-Version: $($staging.VersionInfo.Version)" } - elseif (-not $cert.HasPrivateKey) { - Write-Fail "Konfiguriertes Zertifikat besitzt keinen privaten Schlüssel." + catch { + Write-Fail "Repair fehlgeschlagen: $($_.Exception.Message)" + + if ($backupPath -and (Test-Path -LiteralPath $backupPath)) { + Write-Warn "Stelle vorherige Programmdateien wieder her..." + + try { + Stop-RelayTask + Restore-ManagedFiles -BackupPath $backupPath -TargetPath $InstallPath + Ensure-ScheduledTask -TargetPath $InstallPath + Start-RelayTask + Write-Ok "Rollback nach Repair abgeschlossen." + } + catch { + Write-Fail "Repair-Rollback fehlgeschlagen: $($_.Exception.Message)" + } + } } - else { - Write-Ok "Zertifikat vorhanden und besitzt Private Key." + finally { + Remove-RepoStagingArea -Staging $staging } - - 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 { @@ -1338,7 +1451,7 @@ function Show-Status { function Show-Menu { Clear-Host - Write-Title "SMTPGraphRelay - Installer / Repair / Update" + Write-Title "SMTPGraphRelay - Bootstrap / Repair / Online Update" Write-Host "Installationspfad: $InstallPath" -ForegroundColor DarkGray $installedVersion = Get-InstalledVersion -TargetPath $InstallPath @@ -1349,8 +1462,8 @@ function Show-Menu { Write-Host "Updatequelle: Gitea / main" -ForegroundColor DarkGray Write-Host "" - Write-Host " [1] Neuinstallation" - Write-Host " [2] Installation reparieren" + Write-Host " [1] Neuinstallation aus Gitea" + Write-Host " [2] Installation aus Gitea reparieren" Write-Host " [3] Nach Online-Updates suchen" Write-Host " [4] Entra / Exchange RBAC prüfen" Write-Host " [5] Zertifikat erneuern"