diff --git a/README.md b/README.md new file mode 100644 index 0000000..81f8f2e --- /dev/null +++ b/README.md @@ -0,0 +1,247 @@ +# SMTPGraphRelay V1 + +Ein kleiner nativer Windows-/PowerShell-Relay: + +```text +Drucker / NAS / Server / Monitoring + | + | SMTP (lokal) + v + SMTPGraphRelay + | + | HTTPS / OAuth2 / Microsoft Graph + v + Microsoft 365 +``` + +## Was V1 kann + +- SMTP-Listener auf konfigurierbarer IP / Port +- `EHLO`, `HELO`, `MAIL FROM`, mehrere `RCPT TO`, `DATA`, `RSET`, `NOOP`, `QUIT` +- IPv4-Allowlist per CIDR +- maximale Mailgröße +- lokale Store-and-Forward Queue +- Retry bei Graph-/Netzwerkfehlern +- Failed-Queue nach Max-Retries +- Microsoft Graph `sendMail` +- MIME-Mail wird als MIME an Graph weitergegeben +- App-only OAuth mit Zertifikat +- First-Run erzeugt: + - selbstsigniertes Zertifikat in `LocalMachine\My` + - Entra ID App Registration + - Service Principal + - Microsoft Graph `Mail.Send` Application Permission + - Admin Consent + - `config.json` + - Windows-Firewallregel + - Scheduled Task als `SYSTEM` + +## Voraussetzungen + +- Windows 10/11 oder Windows Server +- Windows PowerShell 5.1 +- PowerShell als Administrator +- Internetzugang zu Microsoft Graph / Entra +- M365-/Entra-Admin, der App-Registrierungen und App Permissions vergeben darf +- bestehendes Exchange-Online-Postfach für den konfigurierten Absender + +## Installation + +1. ZIP entpacken, z. B.: + +```powershell +C:\Program Files\SMTPGraphRelay +``` + +2. Windows PowerShell **als Administrator** öffnen. + +3. Setup starten: + +```powershell +Set-ExecutionPolicy -Scope Process Bypass +cd "C:\Program Files\SMTPGraphRelay" +.\Setup-SMTPGraphRelay.ps1 +``` + +Das Setup fragt u. a.: + +- App-Name +- M365-Absenderpostfach +- Listen-IP +- SMTP-Port +- erlaubte Quellnetze + +Danach wird die Entra-App automatisch erstellt. + +## Standard + +Port: + +```text +2525/TCP +``` + +Default-Allowlist: + +```text +127.0.0.1/32 +10.0.0.0/8 +172.16.0.0/12 +192.168.0.0/16 +``` + +Der Absender wird standardmäßig immer auf das konfigurierte M365-Postfach umgeschrieben: + +```json +"ForceSender": true +``` + +Das verhindert, dass ein internes Gerät beliebige `From:`-Adressen durchreichen kann. + +## Start / Stop + +Start: + +```powershell +Start-ScheduledTask -TaskName "SMTPGraphRelay" +``` + +Stop: + +```powershell +Stop-ScheduledTask -TaskName "SMTPGraphRelay" +``` + +Status: + +```powershell +Get-ScheduledTask -TaskName "SMTPGraphRelay" | Get-ScheduledTaskInfo +``` + +## Test vom Relay-PC + +Wenn `Send-MailMessage` noch vorhanden ist: + +```powershell +Send-MailMessage ` + -SmtpServer 127.0.0.1 ` + -Port 2525 ` + -From "test@local.invalid" ` + -To "dein.name@example.com" ` + -Subject "SMTPGraphRelay Test" ` + -Body "Hallo aus SMTPGraphRelay" +``` + +Alternativ kann jedes SMTP-Testtool verwendet werden. + +## Verzeichnisse + +```text +SMTPGraphRelay\ +├── SMTPGraphRelay.ps1 +├── Setup-SMTPGraphRelay.ps1 +├── config.json +├── config.example.json +├── queue\ +├── failed\ +└── logs\ + └── SMTPGraphRelay.log +``` + +## Queue + +Nach vollständigem SMTP-`DATA` wird die Nachricht zunächst lokal gespeichert. + +Erst **danach** bekommt der SMTP-Client: + +```text +250 2.0.0 Queued +``` + +Der Queue-Worker sendet die Nachricht anschließend über Microsoft Graph. + +Standard-Retry: + +```text +1 min +5 min +15 min +30 min +60 min +120 min +240 min +480 min +``` + +Nach acht Fehlern wandert die Mail nach `failed`. + +## Sicherheit + +### Kein offenes Relay + +Die SMTP-Seite besitzt eine IP-Allowlist. Bitte die Standard-RFC1918-Netze auf die tatsächlich benötigten Subnetze reduzieren. + +Beispiel: + +```json +"AllowedNetworks": [ + "10.60.10.0/24", + "10.20.30.15" +] +``` + +### Kein Client Secret + +Das Setup erzeugt ein nicht exportierbares RSA-Zertifikat in: + +```text +Cert:\LocalMachine\My +``` + +Der Scheduled Task läuft als `SYSTEM` und lädt dieses Zertifikat direkt aus dem Maschinen-Zertifikatsspeicher. + +### Graph-Berechtigung + +Die V1 vergibt ausschließlich: + +```text +Microsoft Graph +Application +Mail.Send +``` + +Keine `Mail.ReadWrite`, `Directory.Read.All`, SMTP-/IMAP- oder Exchange-Full-Access-Permission ist für den Relaybetrieb nötig. + +**Wichtig:** `Mail.Send` als Application Permission ist grundsätzlich eine weitreichende Berechtigung. Die V1 erzwingt zwar lokal das konfigurierte Senderpostfach, beschränkt die Entra-/Exchange-Berechtigung aber noch nicht serverseitig auf genau dieses Postfach. + +Für eine nächste Version sollte zusätzlich **Exchange Online Application RBAC** bzw. die jeweils aktuelle Microsoft-Methode zur Ressourcenscope-Begrenzung integriert werden. + +## Bekannte Grenzen von V1 + +- SMTP-Verbindungen werden seriell verarbeitet +- kein SMTP AUTH +- kein STARTTLS auf der internen SMTP-Seite +- IPv4-Allowlist; IPv6 wird nicht freigegeben +- kein Web-/GUI-Frontend +- keine DSN/Bounce-Erzeugung +- Envelope-Empfänger werden protokolliert; Graph erhält primär die Empfänger aus den MIME-Headern +- kein serverseitiges Exchange-Mailbox-Scoping im Setup + +Für typische Drucker, Scanner, NAS, Monitoring- und Server-Alerts sollte diese V1 als Test-/Pilotversion ausreichen. + +## Deinstallation + +Task stoppen/löschen: + +```powershell +Stop-ScheduledTask -TaskName "SMTPGraphRelay" -ErrorAction SilentlyContinue +Unregister-ScheduledTask -TaskName "SMTPGraphRelay" -Confirm:$false +``` + +Firewallregel entfernen (Port ggf. anpassen): + +```powershell +Remove-NetFirewallRule -DisplayName "SMTPGraphRelay TCP 2525" +``` + +Die Entra-App und das Zertifikat werden absichtlich **nicht automatisch gelöscht**, damit bei einer Deinstallation keine Cloud-Credentials versehentlich entfernt werden. diff --git a/SMTPGraphRelay.ps1 b/SMTPGraphRelay.ps1 new file mode 100644 index 0000000..79fd668 --- /dev/null +++ b/SMTPGraphRelay.ps1 @@ -0,0 +1,468 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + SMTPGraphRelay - einfacher SMTP Store-and-Forward Relay zu Microsoft Graph. + +.DESCRIPTION + Nimmt lokale SMTP-Mails an, speichert sie als .eml in einer Queue und sendet sie + anschließend per Microsoft Graph sendMail mit App-only Zertifikatsauthentifizierung. + + V1: EHLO/HELO, MAIL FROM, RCPT TO, DATA, RSET, NOOP, QUIT +#> + +[CmdletBinding()] +param( + [string]$ConfigPath = "$PSScriptRoot\config.json" +) + +$ErrorActionPreference = "Stop" +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + +function Write-Log { + param( + [Parameter(Mandatory)][string]$Message, + [ValidateSet("INFO","WARN","ERROR","DEBUG")][string]$Level = "INFO" + ) + + $ts = Get-Date -Format "yyyy-MM-dd HH:mm:ss.fff" + $line = "[$ts] [$Level] $Message" + Write-Host $line + + try { + if ($script:Config -and $script:Config.Paths.Logs) { + $logDir = $script:Config.Paths.Logs + if (-not [IO.Path]::IsPathRooted($logDir)) { $logDir = Join-Path $PSScriptRoot $logDir } + New-Item -ItemType Directory -Path $logDir -Force | Out-Null + Add-Content -LiteralPath (Join-Path $logDir "SMTPGraphRelay.log") -Value $line -Encoding UTF8 + } + } catch {} +} + +function Resolve-PathFromConfig { + param([Parameter(Mandatory)][string]$Path) + if ([IO.Path]::IsPathRooted($Path)) { return $Path } + return (Join-Path $PSScriptRoot $Path) +} + +function Test-IPv4InCidr { + param( + [Parameter(Mandatory)][System.Net.IPAddress]$Address, + [Parameter(Mandatory)][string]$Cidr + ) + + if ($Address.AddressFamily -ne [System.Net.Sockets.AddressFamily]::InterNetwork) { + return $false + } + + if ($Cidr -notmatch '^(.+)/(\d{1,2})$') { return $false } + + try { + $network = [System.Net.IPAddress]::Parse($matches[1]) + } catch { + return $false + } + + if ($network.AddressFamily -ne [System.Net.Sockets.AddressFamily]::InterNetwork) { + return $false + } + + $prefix = [int]$matches[2] + if ($prefix -lt 0 -or $prefix -gt 32) { return $false } + + $ipBytes = $Address.GetAddressBytes() + $netBytes = $network.GetAddressBytes() + + for ($i = 0; $i -lt 4; $i++) { + $remaining = $prefix - ($i * 8) + if ($remaining -le 0) { break } + + $bits = [Math]::Min(8, $remaining) + [byte]$mask = (0xFF -shl (8 - $bits)) -band 0xFF + + if (($ipBytes[$i] -band $mask) -ne ($netBytes[$i] -band $mask)) { + return $false + } + } + + return $true +} + +function Test-ClientAllowed { + param([Parameter(Mandatory)][System.Net.IPAddress]$Address) + + foreach ($entry in @($script:Config.Smtp.AllowedNetworks)) { + if ($entry -eq "*") { return $true } + try { + if ($entry -match '/') { + if (Test-IPv4InCidr -Address $Address -Cidr $entry) { return $true } + } elseif ([System.Net.IPAddress]::Parse($entry).Equals($Address)) { + return $true + } + } catch {} + } + return $false +} + +function Get-GraphConnection { + if (-not (Get-Module -ListAvailable -Name Microsoft.Graph.Authentication)) { + throw "Microsoft.Graph.Authentication ist nicht installiert." + } + + Import-Module Microsoft.Graph.Authentication -ErrorAction Stop + + $cert = Get-Item -LiteralPath ("Cert:\LocalMachine\My\{0}" -f $script:Config.Graph.CertificateThumbprint) -ErrorAction Stop + + $ctx = Get-MgContext + $needsConnect = $true + if ($ctx) { + if ($ctx.ClientId -eq $script:Config.Graph.ClientId -and $ctx.TenantId -eq $script:Config.Graph.TenantId -and $ctx.AuthType -eq "AppOnly") { + $needsConnect = $false + } + } + + if ($needsConnect) { + Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null + Connect-MgGraph ` + -TenantId $script:Config.Graph.TenantId ` + -ClientId $script:Config.Graph.ClientId ` + -Certificate $cert ` + -NoWelcome | Out-Null + } +} + +function Set-MimeSender { + param( + [Parameter(Mandatory)][byte[]]$MimeBytes, + [Parameter(Mandatory)][string]$Sender + ) + + # Headerbereich als Latin1 lesen, damit Bytes 1:1 erhalten bleiben. + $latin1 = [System.Text.Encoding]::GetEncoding(28591) + $text = $latin1.GetString($MimeBytes) + $separator = "`r`n`r`n" + $idx = $text.IndexOf($separator) + if ($idx -lt 0) { + $separator = "`n`n" + $idx = $text.IndexOf($separator) + } + if ($idx -lt 0) { return $MimeBytes } + + $headers = $text.Substring(0, $idx) + $body = $text.Substring($idx + $separator.Length) + + if ($headers -match '(?im)^From:.*(?:\r?\n[ \t].*)*') { + $headers = [regex]::Replace( + $headers, + '(?im)^From:.*(?:\r?\n[ \t].*)*', + "From: <$Sender>", + 1 + ) + } else { + $headers = "From: <$Sender>`r`n" + $headers + } + + return $latin1.GetBytes($headers + "`r`n`r`n" + $body) +} + +function Send-QueuedMail { + param([Parameter(Mandatory)][string]$FilePath) + + $metaPath = "$FilePath.json" + $meta = $null + if (Test-Path -LiteralPath $metaPath) { + $meta = Get-Content -LiteralPath $metaPath -Raw -Encoding UTF8 | ConvertFrom-Json + } + + $bytes = [IO.File]::ReadAllBytes($FilePath) + + if ($script:Config.Graph.ForceSender) { + $bytes = Set-MimeSender -MimeBytes $bytes -Sender $script:Config.Graph.SenderMailbox + } + + Get-GraphConnection + + $sender = [Uri]::EscapeDataString($script:Config.Graph.SenderMailbox) + $uri = "https://graph.microsoft.com/v1.0/users/$sender/sendMail" + $base64 = [Convert]::ToBase64String($bytes) + + try { + Invoke-MgGraphRequest ` + -Method POST ` + -Uri $uri ` + -Body $base64 ` + -ContentType "text/plain" ` + -OutputType PSObject | Out-Null + + Write-Log "Mail gesendet: $(Split-Path $FilePath -Leaf)" + Remove-Item -LiteralPath $FilePath -Force + if (Test-Path -LiteralPath $metaPath) { Remove-Item -LiteralPath $metaPath -Force } + return $true + } + catch { + $retryCount = 0 + if ($meta -and $null -ne $meta.RetryCount) { $retryCount = [int]$meta.RetryCount } + $retryCount++ + + $maxRetries = [int]$script:Config.Queue.MaxRetries + Write-Log "Graph-Versand fehlgeschlagen (Versuch $retryCount/$maxRetries): $($_.Exception.Message)" "WARN" + + if ($retryCount -ge $maxRetries) { + $failedDir = Resolve-PathFromConfig $script:Config.Paths.Failed + New-Item -ItemType Directory -Path $failedDir -Force | Out-Null + Move-Item -LiteralPath $FilePath -Destination (Join-Path $failedDir (Split-Path $FilePath -Leaf)) -Force + if (Test-Path -LiteralPath $metaPath) { + Move-Item -LiteralPath $metaPath -Destination (Join-Path $failedDir (Split-Path $metaPath -Leaf)) -Force + } + Write-Log "Mail nach $retryCount Fehlversuchen nach FAILED verschoben." "ERROR" + } else { + $delays = @($script:Config.Queue.RetryMinutes) + $delay = if ($retryCount -le $delays.Count) { [int]$delays[$retryCount - 1] } else { [int]$delays[-1] } + $next = (Get-Date).AddMinutes($delay) + + $newMeta = [ordered]@{ + RetryCount = $retryCount + NextAttemptUtc = $next.ToUniversalTime().ToString("o") + LastError = $_.Exception.Message + } + $newMeta | ConvertTo-Json | Set-Content -LiteralPath $metaPath -Encoding UTF8 + } + return $false + } +} + +function Process-Queue { + $queueDir = Resolve-PathFromConfig $script:Config.Paths.Queue + New-Item -ItemType Directory -Path $queueDir -Force | Out-Null + + foreach ($file in Get-ChildItem -LiteralPath $queueDir -Filter "*.eml" -File | Sort-Object CreationTimeUtc) { + $metaPath = "$($file.FullName).json" + if (Test-Path -LiteralPath $metaPath) { + try { + $meta = Get-Content -LiteralPath $metaPath -Raw -Encoding UTF8 | ConvertFrom-Json + if ($meta.NextAttemptUtc) { + $next = [DateTime]::Parse($meta.NextAttemptUtc).ToUniversalTime() + if ($next -gt [DateTime]::UtcNow) { continue } + } + } catch {} + } + + [void](Send-QueuedMail -FilePath $file.FullName) + } +} + +function Save-SmtpMessage { + param( + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [AllowEmptyString()] + [System.Collections.Generic.List[string]]$Lines, + + [Parameter(Mandatory)] + [string]$MailFrom, + + [Parameter(Mandatory)] + [string[]]$Recipients, + + [Parameter(Mandatory)] + [string]$RemoteAddress + ) + + $queueDir = Resolve-PathFromConfig $script:Config.Paths.Queue + New-Item -ItemType Directory -Path $queueDir -Force | Out-Null + + $id = "{0}-{1}" -f (Get-Date -Format "yyyyMMdd-HHmmssfff"), ([guid]::NewGuid().ToString("N").Substring(0,8)) + $path = Join-Path $queueDir "$id.eml" + + # SMTP DATA wird in CRLF normalisiert. + $raw = ($Lines -join "`r`n") + "`r`n" + $utf8NoBom = New-Object System.Text.UTF8Encoding($false) + [IO.File]::WriteAllText($path, $raw, $utf8NoBom) + + $meta = [ordered]@{ + ReceivedUtc = [DateTime]::UtcNow.ToString("o") + RemoteAddress = $RemoteAddress + EnvelopeFrom = $MailFrom + EnvelopeRecipients = @($Recipients) + RetryCount = 0 + NextAttemptUtc = [DateTime]::UtcNow.ToString("o") + } + $meta | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath "$path.json" -Encoding UTF8 + + Write-Log "Mail angenommen: $id | Von=$MailFrom | An=$($Recipients -join ', ') | Client=$RemoteAddress" + return $path +} + +function Write-SmtpLine { + param( + [Parameter(Mandatory)][System.IO.StreamWriter]$Writer, + [Parameter(Mandatory)][string]$Line + ) + $Writer.WriteLine($Line) + $Writer.Flush() +} + +function Handle-SmtpClient { + param([Parameter(Mandatory)][System.Net.Sockets.TcpClient]$Client) + + $remote = $Client.Client.RemoteEndPoint + $remoteIp = ([System.Net.IPEndPoint]$remote).Address + Write-Log "SMTP-Verbindung von $remoteIp" + + if (-not (Test-ClientAllowed -Address $remoteIp)) { + try { + $stream = $Client.GetStream() + $writer = New-Object System.IO.StreamWriter($stream, [System.Text.Encoding]::ASCII) + $writer.NewLine = "`r`n" + $writer.AutoFlush = $true + Write-SmtpLine $writer "554 5.7.1 Client not allowed" + } catch {} + $Client.Close() + Write-Log "Client abgewiesen: $remoteIp" "WARN" + return + } + + $stream = $Client.GetStream() + $stream.ReadTimeout = [int]$script:Config.Smtp.ClientTimeoutSeconds * 1000 + $reader = New-Object System.IO.StreamReader($stream, [System.Text.Encoding]::UTF8, $true, 4096, $true) + $writer = New-Object System.IO.StreamWriter($stream, [System.Text.Encoding]::ASCII, 4096, $true) + $writer.NewLine = "`r`n" + $writer.AutoFlush = $true + + $mailFrom = $null + $recipients = New-Object System.Collections.Generic.List[string] + + Write-SmtpLine $writer ("220 {0} SMTPGraphRelay ready" -f $script:Config.Smtp.Hostname) + + try { + while ($Client.Connected) { + $line = $reader.ReadLine() + if ($null -eq $line) { break } + + if ($line -match '^(?i)(EHLO|HELO)\s+(.+)$') { + Write-SmtpLine $writer ("250-{0}" -f $script:Config.Smtp.Hostname) + Write-SmtpLine $writer ("250-SIZE {0}" -f ([int64]$script:Config.Smtp.MaxMessageSizeMB * 1024 * 1024)) + Write-SmtpLine $writer "250 8BITMIME" + } + elseif ($line -match '^(?i)MAIL FROM:\s*<([^>]*)>') { + $mailFrom = $matches[1] + $recipients.Clear() + Write-SmtpLine $writer "250 2.1.0 OK" + } + elseif ($line -match '^(?i)RCPT TO:\s*<([^>]+)>') { + if (-not $mailFrom) { + Write-SmtpLine $writer "503 5.5.1 Need MAIL FROM first" + continue + } + $recipients.Add($matches[1]) + Write-SmtpLine $writer "250 2.1.5 OK" + } + elseif ($line -match '^(?i)DATA\s*$') { + if (-not $mailFrom -or $recipients.Count -eq 0) { + Write-SmtpLine $writer "503 5.5.1 Need MAIL FROM and RCPT TO first" + continue + } + + Write-SmtpLine $writer "354 End data with ." + $data = New-Object System.Collections.Generic.List[string] + $size = 0 + $maxBytes = [int64]$script:Config.Smtp.MaxMessageSizeMB * 1024 * 1024 + $tooLarge = $false + + while ($true) { + $dataLine = $reader.ReadLine() + if ($null -eq $dataLine) { throw "Client disconnected during DATA" } + if ($dataLine -eq ".") { break } + + # SMTP dot-stuffing rückgängig machen + if ($dataLine.StartsWith("..")) { $dataLine = $dataLine.Substring(1) } + + $size += [System.Text.Encoding]::UTF8.GetByteCount($dataLine) + 2 + if ($size -gt $maxBytes) { + $tooLarge = $true + } elseif (-not $tooLarge) { + $data.Add($dataLine) + } + } + + if ($tooLarge) { + Write-SmtpLine $writer "552 5.3.4 Message size exceeds fixed maximum message size" + Write-Log "Mail von $remoteIp wegen Größenlimit verworfen." "WARN" + } else { + [void](Save-SmtpMessage -Lines $data -MailFrom $mailFrom -Recipients $recipients.ToArray() -RemoteAddress $remoteIp.ToString()) + Write-SmtpLine $writer "250 2.0.0 Queued" + } + + $mailFrom = $null + $recipients.Clear() + } + elseif ($line -match '^(?i)RSET\s*$') { + $mailFrom = $null + $recipients.Clear() + Write-SmtpLine $writer "250 2.0.0 Reset" + } + elseif ($line -match '^(?i)NOOP(?:\s+.*)?$') { + Write-SmtpLine $writer "250 2.0.0 OK" + } + elseif ($line -match '^(?i)QUIT\s*$') { + Write-SmtpLine $writer "221 2.0.0 Bye" + break + } + elseif ($line -match '^(?i)(AUTH|STARTTLS)\b') { + Write-SmtpLine $writer "502 5.5.1 Command not implemented" + } + else { + Write-SmtpLine $writer "500 5.5.2 Command unrecognized" + } + } + } + catch { + Write-Log "SMTP-Clientfehler ${remoteIp}: $($_.Exception.Message)" "WARN" + } + finally { + try { $reader.Dispose() } catch {} + try { $writer.Dispose() } catch {} + try { $stream.Dispose() } catch {} + try { $Client.Close() } catch {} + } +} + +if (-not (Test-Path -LiteralPath $ConfigPath)) { + throw "Konfiguration nicht gefunden: $ConfigPath. Bitte zuerst Setup-SMTPGraphRelay.ps1 ausführen." +} + +$script:Config = Get-Content -LiteralPath $ConfigPath -Raw -Encoding UTF8 | ConvertFrom-Json + +foreach ($p in @($script:Config.Paths.Queue, $script:Config.Paths.Failed, $script:Config.Paths.Logs)) { + New-Item -ItemType Directory -Path (Resolve-PathFromConfig $p) -Force | Out-Null +} + +$listenIp = [System.Net.IPAddress]::Parse($script:Config.Smtp.ListenAddress) +$listener = [System.Net.Sockets.TcpListener]::new($listenIp, [int]$script:Config.Smtp.Port) +$listener.Start() + +Write-Log "SMTPGraphRelay gestartet auf $($script:Config.Smtp.ListenAddress):$($script:Config.Smtp.Port)" +Write-Log "Graph-Absender: $($script:Config.Graph.SenderMailbox)" + +$lastQueueRun = [DateTime]::MinValue + +try { + while ($true) { + if ((Get-Date) -gt $lastQueueRun.AddSeconds([int]$script:Config.Queue.PollSeconds)) { + try { Process-Queue } catch { Write-Log "Queue-Worker: $($_.Exception.Message)" "ERROR" } + $lastQueueRun = Get-Date + } + + if ($listener.Pending()) { + $client = $listener.AcceptTcpClient() + # V1 verarbeitet Clients seriell. Für typische Geräte-/Monitoring-Relays bewusst simpel. + Handle-SmtpClient -Client $client + } else { + Start-Sleep -Milliseconds 200 + } + } +} +finally { + $listener.Stop() + Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null + Write-Log "SMTPGraphRelay beendet." +} diff --git a/Setup-SMTPGraphRelay.ps1 b/Setup-SMTPGraphRelay.ps1 new file mode 100644 index 0000000..5ad41b5 --- /dev/null +++ b/Setup-SMTPGraphRelay.ps1 @@ -0,0 +1,334 @@ +#Requires -Version 5.1 +#Requires -RunAsAdministrator +<# +.SYNOPSIS + First-Run Setup für SMTPGraphRelay. + +.DESCRIPTION + - Installiert notwendige Microsoft Graph PowerShell Module + - Meldet interaktiv einen Entra-Administrator an + - Erstellt ein selbstsigniertes Zertifikat in LocalMachine\My + - Erstellt eine Entra ID App Registration + - Fügt das Zertifikat als Credential hinzu + - Vergibt Microsoft Graph Application Permission Mail.Send + - Erteilt Admin Consent über AppRoleAssignment + - Schreibt config.json + - Legt Firewallregel und Scheduled Task an +#> + +[CmdletBinding()] +param() + +$ErrorActionPreference = "Stop" +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + +# ------------------------------------------------------------------------------ +# WICHTIG: Dieses Setup muss mit Windows PowerShell 5.1 gestartet werden. +# Der eigentliche SMTPGraphRelay Scheduled Task verwendet ebenfalls powershell.exe +# (Windows PowerShell 5.1). Wird das Setup stattdessen mit PowerShell 7 / pwsh.exe +# ausgeführt, können Microsoft.Graph-Module im falschen Modulpfad landen und sind +# später für den SYSTEM-Task unter Windows PowerShell 5.1 nicht sichtbar. +# ------------------------------------------------------------------------------ + +if ($PSVersionTable.PSEdition -ne "Desktop" -or $PSVersionTable.PSVersion.Major -ne 5) { + Write-Host "" + Write-Host "==========================================================" -ForegroundColor Red + Write-Host " FALSCHE POWERSHELL-VERSION" -ForegroundColor Red + Write-Host "==========================================================" -ForegroundColor Red + Write-Host "" + Write-Host "Dieses Setup muss mit Windows PowerShell 5.1 ausgefuehrt werden." -ForegroundColor Yellow + Write-Host "" + Write-Host "Aktuell erkannt:" -ForegroundColor Cyan + Write-Host " Edition: $($PSVersionTable.PSEdition)" + Write-Host " Version: $($PSVersionTable.PSVersion)" + Write-Host "" + Write-Host "Bitte eine klassische Windows PowerShell oeffnen:" -ForegroundColor Cyan + Write-Host " C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe" + Write-Host "" + Write-Host "Danach das Setup dort erneut starten." -ForegroundColor Yellow + Write-Host "" + Read-Host "Enter druecken zum Beenden" + exit 1 +} + +Write-Host "Windows PowerShell erkannt: $($PSVersionTable.PSVersion)" -ForegroundColor Green +Write-Host "" + +function Read-Default { + param([string]$Prompt, [string]$Default) + $value = Read-Host "$Prompt [Standard: $Default]" + if ([string]::IsNullOrWhiteSpace($value)) { return $Default } + return $value +} + +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() + } +} + +Write-Host "==========================================================" -ForegroundColor Cyan +Write-Host " SMTPGraphRelay - First Run / Entra ID Setup" -ForegroundColor Cyan +Write-Host "==========================================================" -ForegroundColor Cyan +Write-Host "" + +Write-Host "Schritt 0: Voraussetzungen prüfen..." -ForegroundColor Cyan + +$modules = @( + "Microsoft.Graph.Authentication", + "Microsoft.Graph.Applications" +) + +foreach ($module in $modules) { + if (-not (Get-Module -ListAvailable -Name $module)) { + Write-Host "-> $module fehlt. Wird für alle Benutzer installiert..." -ForegroundColor Yellow + Install-Module $module -Scope AllUsers -Repository PSGallery -Force -AllowClobber + } +} + +Import-Module Microsoft.Graph.Authentication -ErrorAction Stop +Import-Module Microsoft.Graph.Applications -ErrorAction Stop +Write-Host "-> Voraussetzungen erfüllt." -ForegroundColor Green +Write-Host "" + +Write-Host "Schritt 1: Relay-Konfiguration erfassen" -ForegroundColor Cyan + +$AppName = Read-Default "-> Name der Entra App" "SMTPGraphRelay" +$SenderMailbox = Read-Host "-> M365-Absenderpostfach (z.B. smtp-relay@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") +$AllowedNetworksText = 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 = @($AllowedNetworksText -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + +Write-Host "" +Write-Host "Schritt 2: Erzeuge Zertifikat für unbeaufsichtigte Graph-Anmeldung..." -ForegroundColor Cyan + +$certSubject = "CN=SMTPGraphRelay-$env:COMPUTERNAME" +$cert = New-SelfSignedCertificate ` + -Subject $certSubject ` + -CertStoreLocation "Cert:\LocalMachine\My" ` + -KeyAlgorithm RSA ` + -KeyLength 2048 ` + -HashAlgorithm SHA256 ` + -KeyExportPolicy NonExportable ` + -KeySpec Signature ` + -NotAfter (Get-Date).AddYears(2) + +Write-Host "-> Zertifikat erstellt: $($cert.Thumbprint)" -ForegroundColor Green +Write-Host "" + +Write-Host "Schritt 3: Mit Microsoft Graph anmelden..." -ForegroundColor Cyan +Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null + +# Application.ReadWrite.All: App + Service Principal erstellen +# AppRoleAssignment.ReadWrite.All: Mail.Send als AppRoleAssignment (Admin Consent) erteilen +Connect-MgGraph -Scopes "Application.ReadWrite.All","AppRoleAssignment.ReadWrite.All" -NoWelcome + +$TenantId = (Get-MgContext).TenantId +Write-Host "-> Verbunden mit Tenant: $TenantId" -ForegroundColor Green +Write-Host "" + +Write-Host "Schritt 4: Microsoft Graph Mail.Send Application Permission ermitteln..." -ForegroundColor Cyan + +$GraphSp = Get-MgServicePrincipal -Filter "appId eq '00000003-0000-0000-c000-000000000000'" +if (-not $GraphSp) { throw "Microsoft Graph Service Principal wurde im Tenant nicht gefunden." } + +$MailSend = $GraphSp.AppRoles | Where-Object { + $_.Value -eq "Mail.Send" -and $_.AllowedMemberTypes -contains "Application" +} | Select-Object -First 1 + +if (-not $MailSend) { throw "Graph Application Permission Mail.Send wurde nicht gefunden." } + +Write-Host "-> Mail.Send AppRole-ID: $($MailSend.Id)" -ForegroundColor Green +Write-Host "" + +Write-Host "Schritt 5: Entra ID App Registration erstellen..." -ForegroundColor Cyan + +$AppParams = @{ + DisplayName = $AppName + SignInAudience = "AzureADMyOrg" + RequiredResourceAccess = @( + @{ + ResourceAppId = "00000003-0000-0000-c000-000000000000" + ResourceAccess = @( + @{ + Id = $MailSend.Id + Type = "Role" + } + ) + } + ) + KeyCredentials = @( + (Convert-CertToKeyCredential -Certificate $cert) + ) +} + +$App = New-MgApplication -BodyParameter $AppParams +Write-Host "-> App erstellt: $($App.AppId)" -ForegroundColor Green + +$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 "Service Principal konnte nicht erstellt werden." } + +Write-Host "-> Service Principal erstellt: $($Sp.Id)" -ForegroundColor Green +Write-Host "" + +Write-Host "Schritt 6: Admin Consent für Mail.Send erteilen..." -ForegroundColor Cyan + +New-MgServicePrincipalAppRoleAssignment ` + -ServicePrincipalId $Sp.Id ` + -PrincipalId $Sp.Id ` + -ResourceId $GraphSp.Id ` + -AppRoleId $MailSend.Id | Out-Null + +Write-Host "-> Mail.Send wurde als Application Permission erteilt." -ForegroundColor Green +Write-Host "" + +Write-Host "Schritt 7: config.json schreiben..." -ForegroundColor Cyan + +$config = [ordered]@{ + Smtp = [ordered]@{ + ListenAddress = $ListenAddress + Port = $Port + Hostname = $env:COMPUTERNAME + AllowedNetworks = $AllowedNetworks + MaxMessageSizeMB = 25 + ClientTimeoutSeconds = 120 + } + Graph = [ordered]@{ + TenantId = $TenantId + ClientId = $App.AppId + CertificateThumbprint = $cert.Thumbprint + SenderMailbox = $SenderMailbox + ForceSender = $true + } + Queue = [ordered]@{ + PollSeconds = 10 + MaxRetries = 8 + RetryMinutes = @(1,5,15,30,60,120,240,480) + } + Paths = [ordered]@{ + Queue = "queue" + Failed = "failed" + Logs = "logs" + } +} + +$configPath = Join-Path $PSScriptRoot "config.json" +$config | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $configPath -Encoding UTF8 + +foreach ($dir in @("queue","failed","logs")) { + New-Item -ItemType Directory -Path (Join-Path $PSScriptRoot $dir) -Force | Out-Null +} + +Write-Host "-> Konfiguration: $configPath" -ForegroundColor Green +Write-Host "" + +Write-Host "Schritt 8: Windows Firewall konfigurieren..." -ForegroundColor Cyan +$ruleName = "SMTPGraphRelay TCP $Port" +Get-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue | Remove-NetFirewallRule -ErrorAction SilentlyContinue +New-NetFirewallRule ` + -DisplayName $ruleName ` + -Direction Inbound ` + -Action Allow ` + -Protocol TCP ` + -LocalPort $Port ` + -Profile Any | Out-Null +Write-Host "-> Firewallregel erstellt." -ForegroundColor Green +Write-Host "" + +Write-Host "Schritt 9: Autostart als Scheduled Task anlegen..." -ForegroundColor Cyan + +$taskName = "SMTPGraphRelay" +$scriptPath = Join-Path $PSScriptRoot "SMTPGraphRelay.ps1" +$psExe = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" + +$action = New-ScheduledTaskAction ` + -Execute $psExe ` + -Argument "-NoLogo -NoProfile -ExecutionPolicy Bypass -File `"$scriptPath`"" + +$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-Host "-> Scheduled Task '$taskName' erstellt." -ForegroundColor Green +Write-Host "" + +Write-Host "Schritt 10: Teste App-only Graph-Anmeldung..." -ForegroundColor Cyan +Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null +Connect-MgGraph ` + -TenantId $TenantId ` + -ClientId $App.AppId ` + -Certificate $cert ` + -NoWelcome | Out-Null + +$ctx = Get-MgContext +if ($ctx.AuthType -ne "AppOnly") { + throw "App-only Graph-Anmeldung konnte nicht bestätigt werden." +} +Write-Host "-> App-only Graph-Anmeldung funktioniert." -ForegroundColor Green +Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null +Write-Host "" + +Write-Host "==========================================================" -ForegroundColor Green +Write-Host " SMTPGraphRelay wurde eingerichtet." -ForegroundColor Green +Write-Host "==========================================================" -ForegroundColor Green +Write-Host "" +Write-Host " Tenant ID: $TenantId" -ForegroundColor Yellow +Write-Host " Client ID: $($App.AppId)" -ForegroundColor Yellow +Write-Host " Zertifikat: $($cert.Thumbprint)" -ForegroundColor Yellow +Write-Host " Graph-Absender: $SenderMailbox" -ForegroundColor Yellow +Write-Host " SMTP Listener: $ListenAddress`:$Port" -ForegroundColor Yellow +Write-Host "" +Write-Host "WICHTIG:" -ForegroundColor Yellow +Write-Host "Mail.Send als Application Permission ist tenantweit mächtig." +Write-Host "Diese V1 erzwingt lokal SenderMailbox='$SenderMailbox', begrenzt die" +Write-Host "Graph-Berechtigung selbst aber noch nicht per Exchange Application RBAC." +Write-Host "" +Write-Host "Task starten mit:" -ForegroundColor Cyan +Write-Host " Start-ScheduledTask -TaskName `"$taskName`"" +Write-Host "" +Write-Host "Logs:" -ForegroundColor Cyan +Write-Host " $(Join-Path $PSScriptRoot 'logs\SMTPGraphRelay.log')" +Write-Host "" + +$startNow = Read-Host "Relay jetzt starten? [J/n]" +if ([string]::IsNullOrWhiteSpace($startNow) -or $startNow -match '^(?i)j|ja|y|yes$') { + Start-ScheduledTask -TaskName $taskName + Start-Sleep -Seconds 2 + Write-Host "-> Task gestartet." -ForegroundColor Green +} + +Read-Host "Enter drücken zum Beenden" diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..06284f1 --- /dev/null +++ b/config.example.json @@ -0,0 +1,41 @@ +{ + "Smtp": { + "ListenAddress": "0.0.0.0", + "Port": 2525, + "Hostname": "SMTPRELAY01", + "AllowedNetworks": [ + "127.0.0.1/32", + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16" + ], + "MaxMessageSizeMB": 25, + "ClientTimeoutSeconds": 120 + }, + "Graph": { + "TenantId": "", + "ClientId": "", + "CertificateThumbprint": "", + "SenderMailbox": "smtp-relay@example.com", + "ForceSender": true + }, + "Queue": { + "PollSeconds": 10, + "MaxRetries": 8, + "RetryMinutes": [ + 1, + 5, + 15, + 30, + 60, + 120, + 240, + 480 + ] + }, + "Paths": { + "Queue": "queue", + "Failed": "failed", + "Logs": "logs" + } +} \ No newline at end of file