Dateien nach "/" hochladen

This commit is contained in:
2026-08-14 00:13:11 +02:00
parent 45e55c5113
commit 50334ec1cb
3 changed files with 3096 additions and 18 deletions
+491 -18
View File
@@ -7,7 +7,7 @@
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.6: Queue-ID/Received/Message-ID sowie SMTP-/Queue-Backpressure und Session-Limits
V1.7: SMTP AUTH LOGIN/PLAIN mit PBKDF2-SHA256, Benutzer-/Session-Schutz und V1.6 Queue-/Backpressure-Funktionen
#>
[CmdletBinding()]
@@ -809,6 +809,319 @@ function Add-RelayMessageHeaders {
return $result
}
function Test-AddressInList {
param(
[Parameter(Mandatory)][System.Net.IPAddress]$Address,
[object[]]$Entries
)
foreach ($entry in @($Entries)) {
if ($null -eq $entry) { continue }
$value = [string]$entry
if ([string]::IsNullOrWhiteSpace($value)) { continue }
if ($value -eq "*") { return $true }
try {
if ($value -match '/') {
if (Test-IPv4InCidr -Address $Address -Cidr $value) {
return $true
}
}
elseif ([System.Net.IPAddress]::Parse($value).Equals($Address)) {
return $true
}
}
catch {}
}
return $false
}
function Get-SmtpAuthSettings {
$requireAuth = $false
$maxFailures = 5
$allowUnauthenticatedNetworks = @()
$users = @()
try {
if ($script:Config.Smtp.PSObject.Properties.Name -contains "RequireAuth") {
$requireAuth = [bool]$script:Config.Smtp.RequireAuth
}
if ($script:Config.Smtp.PSObject.Properties.Name -contains "AuthMaxFailures") {
$v = [int]$script:Config.Smtp.AuthMaxFailures
if ($v -ge 1 -and $v -le 100) {
$maxFailures = $v
}
}
if ($script:Config.Smtp.PSObject.Properties.Name -contains "AllowUnauthenticatedNetworks") {
$allowUnauthenticatedNetworks = @($script:Config.Smtp.AllowUnauthenticatedNetworks)
}
if ($script:Config.Smtp.PSObject.Properties.Name -contains "AuthUsers") {
$users = @($script:Config.Smtp.AuthUsers)
}
}
catch {}
return [pscustomobject]@{
RequireAuth = $requireAuth
AuthMaxFailures = $maxFailures
AllowUnauthenticatedNetworks = $allowUnauthenticatedNetworks
Users = $users
}
}
function Test-SmtpAuthenticationRequired {
param([Parameter(Mandatory)][System.Net.IPAddress]$Address)
$settings = Get-SmtpAuthSettings
if (-not $settings.RequireAuth) {
return $false
}
if (Test-AddressInList -Address $Address -Entries $settings.AllowUnauthenticatedNetworks) {
return $false
}
return $true
}
function ConvertFrom-Base64Utf8 {
param([Parameter(Mandatory)][string]$Value)
try {
$bytes = [Convert]::FromBase64String($Value)
return [Text.Encoding]::UTF8.GetString($bytes)
}
catch {
throw "Invalid Base64 data"
}
}
function Invoke-Pbkdf2Sha256 {
param(
[Parameter(Mandatory)][string]$Password,
[Parameter(Mandatory)][byte[]]$Salt,
[Parameter(Mandatory)][int]$Iterations,
[int]$Length = 32
)
if ($Iterations -lt 1) {
throw "Iterations must be greater than zero."
}
# Auf unterstützten .NET-Framework-Versionen verwenden wir die native,
# schnelle PBKDF2-SHA256-Implementierung.
try {
$derive = New-Object System.Security.Cryptography.Rfc2898DeriveBytes(
$Password,
$Salt,
$Iterations,
[System.Security.Cryptography.HashAlgorithmName]::SHA256
)
try {
return $derive.GetBytes($Length)
}
finally {
$derive.Dispose()
}
}
catch {
# Kompatibilitäts-Fallback für ältere .NET-Framework-Stände.
$hmac = New-Object System.Security.Cryptography.HMACSHA256
$hmac.Key = [Text.Encoding]::UTF8.GetBytes($Password)
try {
$hashLength = 32
$blocks = [Math]::Ceiling($Length / [double]$hashLength)
$output = New-Object byte[] ($blocks * $hashLength)
$offset = 0
for ($block = 1; $block -le $blocks; $block++) {
$blockBytes = [BitConverter]::GetBytes([int]$block)
if ([BitConverter]::IsLittleEndian) {
[Array]::Reverse($blockBytes)
}
$input = New-Object byte[] ($Salt.Length + 4)
[Array]::Copy($Salt, 0, $input, 0, $Salt.Length)
[Array]::Copy($blockBytes, 0, $input, $Salt.Length, 4)
$u = $hmac.ComputeHash($input)
$t = New-Object byte[] $u.Length
[Array]::Copy($u, $t, $u.Length)
for ($i = 2; $i -le $Iterations; $i++) {
$u = $hmac.ComputeHash($u)
for ($j = 0; $j -lt $t.Length; $j++) {
$t[$j] = $t[$j] -bxor $u[$j]
}
}
[Array]::Copy($t, 0, $output, $offset, $t.Length)
$offset += $t.Length
}
$result = New-Object byte[] $Length
[Array]::Copy($output, 0, $result, 0, $Length)
return $result
}
finally {
$hmac.Dispose()
}
}
}
function Test-FixedTimeEquals {
param(
[Parameter(Mandatory)][byte[]]$A,
[Parameter(Mandatory)][byte[]]$B
)
if ($A.Length -ne $B.Length) {
return $false
}
[int]$difference = 0
for ($i = 0; $i -lt $A.Length; $i++) {
$difference = $difference -bor ($A[$i] -bxor $B[$i])
}
return ($difference -eq 0)
}
function Test-SmtpCredentials {
param(
[Parameter(Mandatory)][string]$Username,
[Parameter(Mandatory)][string]$Password
)
if ([string]::IsNullOrWhiteSpace($Username)) {
return $false
}
$settings = Get-SmtpAuthSettings
$user = $settings.Users |
Where-Object { ([string]$_.Username).Equals($Username, [StringComparison]::OrdinalIgnoreCase) } |
Select-Object -First 1
if (-not $user) {
# Bewusst keine Unterscheidung im SMTP-Result zwischen unbekanntem
# Benutzer und falschem Passwort.
return $false
}
try {
$salt = [Convert]::FromBase64String([string]$user.Salt)
$expected = [Convert]::FromBase64String([string]$user.PasswordHash)
$iterations = [int]$user.Iterations
if ($iterations -lt 10000 -or $expected.Length -lt 16 -or $salt.Length -lt 8) {
return $false
}
$actual = Invoke-Pbkdf2Sha256 `
-Password $Password `
-Salt $salt `
-Iterations $iterations `
-Length $expected.Length
return (Test-FixedTimeEquals -A $actual -B $expected)
}
catch {
return $false
}
}
function Invoke-SmtpAuthLogin {
param(
[Parameter(Mandatory)][System.IO.StreamReader]$Reader,
[Parameter(Mandatory)][System.IO.StreamWriter]$Writer,
[string]$InitialResponse
)
try {
if ([string]::IsNullOrWhiteSpace($InitialResponse)) {
Write-SmtpLine $Writer "334 VXNlcm5hbWU6"
$encodedUser = $Reader.ReadLine()
if ($null -eq $encodedUser) { throw "Client disconnected during AUTH LOGIN" }
}
else {
$encodedUser = $InitialResponse
}
$username = ConvertFrom-Base64Utf8 -Value $encodedUser
Write-SmtpLine $Writer "334 UGFzc3dvcmQ6"
$encodedPassword = $Reader.ReadLine()
if ($null -eq $encodedPassword) { throw "Client disconnected during AUTH LOGIN" }
$password = ConvertFrom-Base64Utf8 -Value $encodedPassword
return [pscustomobject]@{
Valid = (Test-SmtpCredentials -Username $username -Password $password)
Username = $username
ProtocolError = $false
}
}
catch {
return [pscustomobject]@{
Valid = $false
Username = $null
ProtocolError = $true
}
}
}
function Invoke-SmtpAuthPlain {
param(
[Parameter(Mandatory)][System.IO.StreamReader]$Reader,
[Parameter(Mandatory)][System.IO.StreamWriter]$Writer,
[string]$InitialResponse
)
try {
$encoded = $InitialResponse
if ([string]::IsNullOrWhiteSpace($encoded)) {
Write-SmtpLine $Writer "334"
$encoded = $Reader.ReadLine()
if ($null -eq $encoded) { throw "Client disconnected during AUTH PLAIN" }
}
$decoded = ConvertFrom-Base64Utf8 -Value $encoded
$parts = $decoded -split "`0", 3
if ($parts.Count -lt 3) {
throw "Invalid AUTH PLAIN payload"
}
# RFC 4616: [authzid] NUL authcid NUL passwd
$username = $parts[1]
$password = $parts[2]
return [pscustomobject]@{
Valid = (Test-SmtpCredentials -Username $username -Password $password)
Username = $username
ProtocolError = $false
}
}
catch {
return [pscustomobject]@{
Valid = $false
Username = $null
ProtocolError = $true
}
}
}
function Get-SmtpLimits {
$maxRecipients = 50
$maxMessagesPerConnection = 25
@@ -933,6 +1246,8 @@ function Save-SmtpMessage {
[Parameter(Mandatory)]
[string]$RemoteAddress,
[string]$AuthenticatedUser,
[string]$QueueId
)
@@ -961,6 +1276,7 @@ function Save-SmtpMessage {
QueueId = $QueueId
ReceivedUtc = [DateTime]::UtcNow.ToString("o")
RemoteAddress = $RemoteAddress
AuthenticatedUser = $AuthenticatedUser
EnvelopeFrom = $MailFrom
EnvelopeRecipients = @($Recipients)
RetryCount = 0
@@ -985,7 +1301,8 @@ function Save-SmtpMessage {
throw
}
Write-Log "[$QueueId] Mail angenommen | Von=$MailFrom | An=$($Recipients -join ', ') | Client=$RemoteAddress"
$authText = if ([string]::IsNullOrWhiteSpace($AuthenticatedUser)) { "unauthenticated" } else { $AuthenticatedUser }
Write-Log "[$QueueId] Mail angenommen | Von=$MailFrom | An=$($Recipients -join ', ') | Client=$RemoteAddress | Auth=$authText"
return [pscustomobject]@{
QueueId = $QueueId
Path = $pendingEml
@@ -1016,6 +1333,7 @@ function Handle-SmtpClient {
$writer.AutoFlush = $true
Write-SmtpLine $writer "554 5.7.1 Client not allowed"
} catch {}
$Client.Close()
Write-Log "Client abgewiesen: $remoteIp" "WARN"
return
@@ -1023,8 +1341,22 @@ function Handle-SmtpClient {
$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)
$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
@@ -1032,6 +1364,14 @@ function Handle-SmtpClient {
$recipients = New-Object System.Collections.Generic.List[string]
$acceptedMessages = 0
$limits = Get-SmtpLimits
$authSettings = Get-SmtpAuthSettings
$authenticated = $false
$authenticatedUser = $null
$authFailures = 0
$authLocked = $false
$authRequired = Test-SmtpAuthenticationRequired -Address $remoteIp
$authAvailable = (@($authSettings.Users).Count -gt 0)
Write-SmtpLine $writer ("220 {0} SMTPGraphRelay ready" -f $script:Config.Smtp.Hostname)
@@ -1040,12 +1380,112 @@ function Handle-SmtpClient {
$line = $reader.ReadLine()
if ($null -eq $line) { break }
if ($line -match '^(?i)(EHLO|HELO)\s+(.+)$') {
if ($line -match '^(?i)EHLO\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))
if ($authAvailable) {
Write-SmtpLine $writer "250-AUTH LOGIN PLAIN"
}
Write-SmtpLine $writer "250 8BITMIME"
}
elseif ($line -match '^(?i)HELO\s+(.+)$') {
Write-SmtpLine $writer ("250 {0}" -f $script:Config.Smtp.Hostname)
}
elseif ($line -match '^(?i)AUTH\s+LOGIN(?:\s+(\S+))?\s*$') {
if (-not $authAvailable) {
Write-SmtpLine $writer "504 5.7.4 Authentication mechanism unavailable"
continue
}
if ($authenticated) {
Write-SmtpLine $writer "503 5.5.0 Already authenticated"
continue
}
if ($authLocked) {
Write-SmtpLine $writer "454 4.7.0 Too many authentication failures"
continue
}
$initial = $matches[1]
$result = Invoke-SmtpAuthLogin -Reader $reader -Writer $writer -InitialResponse $initial
if ($result.ProtocolError) {
$authFailures++
Write-SmtpLine $writer "501 5.5.2 Invalid authentication data"
}
elseif ($result.Valid) {
$authenticated = $true
$authenticatedUser = $result.Username
$authFailures = 0
Write-SmtpLine $writer "235 2.7.0 Authentication successful"
Write-Log ("SMTP-AUTH LOGIN erfolgreich | Client={0} | Benutzer={1}" -f $remoteIp, $authenticatedUser)
continue
}
else {
$authFailures++
Write-SmtpLine $writer "535 5.7.8 Authentication credentials invalid"
Write-Log ("SMTP-AUTH LOGIN fehlgeschlagen | Client={0} | Benutzer={1}" -f $remoteIp, $result.Username) "WARN"
}
if ($authFailures -ge $authSettings.AuthMaxFailures) {
$authLocked = $true
Write-Log ("SMTP-AUTH gesperrt nach {0} Fehlversuchen | Client={1}" -f $authFailures, $remoteIp) "WARN"
}
}
elseif ($line -match '^(?i)AUTH\s+PLAIN(?:\s+(\S+))?\s*$') {
if (-not $authAvailable) {
Write-SmtpLine $writer "504 5.7.4 Authentication mechanism unavailable"
continue
}
if ($authenticated) {
Write-SmtpLine $writer "503 5.5.0 Already authenticated"
continue
}
if ($authLocked) {
Write-SmtpLine $writer "454 4.7.0 Too many authentication failures"
continue
}
$initial = $matches[1]
$result = Invoke-SmtpAuthPlain -Reader $reader -Writer $writer -InitialResponse $initial
if ($result.ProtocolError) {
$authFailures++
Write-SmtpLine $writer "501 5.5.2 Invalid authentication data"
}
elseif ($result.Valid) {
$authenticated = $true
$authenticatedUser = $result.Username
$authFailures = 0
Write-SmtpLine $writer "235 2.7.0 Authentication successful"
Write-Log ("SMTP-AUTH PLAIN erfolgreich | Client={0} | Benutzer={1}" -f $remoteIp, $authenticatedUser)
continue
}
else {
$authFailures++
Write-SmtpLine $writer "535 5.7.8 Authentication credentials invalid"
Write-Log ("SMTP-AUTH PLAIN fehlgeschlagen | Client={0} | Benutzer={1}" -f $remoteIp, $result.Username) "WARN"
}
if ($authFailures -ge $authSettings.AuthMaxFailures) {
$authLocked = $true
Write-Log ("SMTP-AUTH gesperrt nach {0} Fehlversuchen | Client={1}" -f $authFailures, $remoteIp) "WARN"
}
}
elseif ($line -match '^(?i)AUTH\b') {
Write-SmtpLine $writer "504 5.5.4 Unsupported authentication mechanism"
}
elseif ($line -match '^(?i)MAIL FROM:\s*<([^>]*)>') {
if ($authRequired -and -not $authenticated) {
Write-SmtpLine $writer "530 5.7.0 Authentication required"
continue
}
if ($acceptedMessages -ge $limits.MaxMessagesPerConnection) {
Write-SmtpLine $writer "452 4.5.3 Too many messages in this session"
Write-Log ("SMTP-Session von {0}: Nachrichtenlimit {1} erreicht." -f $remoteIp, $limits.MaxMessagesPerConnection) "WARN"
@@ -1072,6 +1512,11 @@ function Handle-SmtpClient {
Write-SmtpLine $writer "250 2.1.5 OK"
}
elseif ($line -match '^(?i)DATA\s*$') {
if ($authRequired -and -not $authenticated) {
Write-SmtpLine $writer "530 5.7.0 Authentication required"
continue
}
if (-not $mailFrom -or $recipients.Count -eq 0) {
Write-SmtpLine $writer "503 5.5.1 Need MAIL FROM and RCPT TO first"
continue
@@ -1082,8 +1527,6 @@ function Handle-SmtpClient {
continue
}
# Backpressure VOR 354/DATA: Der Client soll große Nachrichtendaten
# gar nicht erst übertragen, wenn wir sie nicht sicher puffern können.
$pressure = Get-QueuePressure
if (-not $pressure.Accept) {
Write-SmtpLine $writer $pressure.SmtpCode
@@ -1092,6 +1535,7 @@ function Handle-SmtpClient {
}
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
@@ -1099,13 +1543,21 @@ function Handle-SmtpClient {
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) }
if ($null -eq $dataLine) {
throw "Client disconnected during DATA"
}
if ($dataLine -eq ".") {
break
}
if ($dataLine.StartsWith("..")) {
$dataLine = $dataLine.Substring(1)
}
$size += [System.Text.Encoding]::UTF8.GetByteCount($dataLine) + 2
if ($size -gt $maxBytes) {
$tooLarge = $true
}
@@ -1127,17 +1579,24 @@ function Handle-SmtpClient {
-MailFrom $mailFrom `
-Recipients $recipients.ToArray() `
-RemoteAddress $remoteIp.ToString() `
-AuthenticatedUser $authenticatedUser `
-QueueId $queueId
$acceptedMessages++
Write-SmtpLine $writer ("250 2.0.0 Message accepted for delivery; queue-id={0}" -f $saved.QueueId)
Write-SmtpLine $writer (
"250 2.0.0 Message accepted for delivery; queue-id={0}" -f $saved.QueueId
)
}
catch {
# Nach DATA darf niemals 250 gesendet werden, wenn die Queue-Datei
# nicht vollständig und atomar gesichert werden konnte.
Write-SmtpLine $writer "451 4.3.0 SMTPGraphRelay queue temporarily unavailable"
Write-Log ("[{0}] Queue-Speicherung fehlgeschlagen | Client={1} | Fehler={2}" -f `
$queueId, $remoteIp, $_.Exception.Message) "ERROR"
Write-Log (
"[{0}] Queue-Speicherung fehlgeschlagen | Client={1} | Fehler={2}" -f `
$queueId,
$remoteIp,
$_.Exception.Message
) "ERROR"
}
}
@@ -1156,7 +1615,7 @@ function Handle-SmtpClient {
Write-SmtpLine $writer "221 2.0.0 Bye"
break
}
elseif ($line -match '^(?i)(AUTH|STARTTLS)\b') {
elseif ($line -match '^(?i)STARTTLS\b') {
Write-SmtpLine $writer "502 5.5.1 Command not implemented"
}
else {
@@ -1424,6 +1883,15 @@ $smtpFunctionNames = @(
"Get-QueueDirectories",
"New-QueueId",
"Add-RelayMessageHeaders",
"Test-AddressInList",
"Get-SmtpAuthSettings",
"Test-SmtpAuthenticationRequired",
"ConvertFrom-Base64Utf8",
"Invoke-Pbkdf2Sha256",
"Test-FixedTimeEquals",
"Test-SmtpCredentials",
"Invoke-SmtpAuthLogin",
"Invoke-SmtpAuthPlain",
"Get-SmtpLimits",
"Get-QueuePressure",
"Save-SmtpMessage",
@@ -1505,7 +1973,7 @@ $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 V1.6 gestartet auf $($script:Config.Smtp.ListenAddress):$($script:Config.Smtp.Port)"
Write-Log "SMTPGraphRelay V1.7 gestartet auf $($script:Config.Smtp.ListenAddress):$($script:Config.Smtp.Port)"
Write-Log "Graph-Absender: $($script:Config.Graph.SenderMailbox)"
Write-Log "Maximale parallele SMTP-Verbindungen: $script:MaxConcurrentClients"
Write-Log "Queue-/Graph-Worker läuft separat vom SMTP-Listener."
@@ -1516,6 +1984,11 @@ Write-Log ("SMTP-Limits: max. {0} Empfänger/Mail, {1} Mails/Verbindung." -f `
Write-Log ("Backpressure: max. {0} Pending-Mails, mindestens {1} MB freier Speicher." -f `
$limits.MaxPendingMessages, $limits.MinFreeDiskSpaceMB)
$authSettings = Get-SmtpAuthSettings
$authMode = if ($authSettings.RequireAuth) { "erforderlich" } else { "optional/deaktiviert" }
Write-Log ("SMTP-AUTH: {0}; {1} Benutzer; max. {2} Fehlversuche/Verbindung." -f `
$authMode, @($authSettings.Users).Count, $authSettings.AuthMaxFailures)
$logSettings = Get-LogSettings
Write-Log ("Log-Rotation: max. {0} MB pro Datei, Aufbewahrung {1} Tage." -f `
$logSettings.MaxFileSizeMB, $logSettings.RetentionDays)