Dateien nach "/" hochladen
This commit is contained in:
@@ -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.
|
||||||
@@ -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 <CR><LF>.<CR><LF>"
|
||||||
|
$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."
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -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": "<wird vom Setup gesetzt>",
|
||||||
|
"ClientId": "<wird vom Setup gesetzt>",
|
||||||
|
"CertificateThumbprint": "<wird vom Setup gesetzt>",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user