#requires -Version 5.1 <# WinMiniFail2Ban.ps1 — лёгкий fail2ban для Windows Server 2022+ - Локальное время в логах/состоянии - Правило фаервола с постоянным именем: "F2B <IP>" - Продление бана ТОЛЬКО при появлении новых событий (lastSeen с миллисекундами) - Внешний whitelist.txt поддерживается (IP и CIDR, # — комментарии) - Авто-ротация лога по размеру/кол-ву/возрасту Логи: C:\ProgramData\WinMiniFail2Ban\win-f2b.log (+ ротации win-f2b_YYYYMMDD_HHMMSS.log) State: C:\ProgramData\WinMiniFail2Ban\state.json #> #region ============================== CONFIG ============================== $Config = [ordered]@{ ObservationMinutes = 10 # окно анализа (мин) BanThreshold = 5 # бан, если попыток >= порога за окно BanMinutes = 60 # длительность бана (мин) ExtendOnRepeat = $true # продлевать бан при новых попытках (после lastSeen) EventIDs = @(4625,4776,4771) # какие события учитывать Whitelist = @( '127.0.0.1','::1', '10.0.0.0/8','172.16.0.0/12','192.168.0.0/16' # добавь свои сети/адреса ) WhitelistFile = "$env:ProgramData\WinMiniFail2Ban\whitelist.txt" # опционально RuleGroup = 'WinMiniFail2Ban' RuleNamePrefix = 'F2B' Profiles = @('Domain','Private','Public') # где блокировать StateDir = "$env:ProgramData\WinMiniFail2Ban" StateFile = "$env:ProgramData\WinMiniFail2Ban\state.json" LogFile = "$env:ProgramData\WinMiniFail2Ban\win-f2b.log" # Ротация лога LogRotate = $true LogMaxSizeMB = 10 # размер текущего win-f2b.log до ротации LogMaxFiles = 7 # сколько ротированных файлов хранить LogMaxAgeDays = 30 # удалять ротации старше N дней } #endregion ================================================================= #region ============================== INIT ================================= New-Item -ItemType Directory -Path $Config.StateDir -Force | Out-Null if (-not (Test-Path $Config.StateFile)) { '{}' | Out-File -FilePath $Config.StateFile -Encoding UTF8 -Force } if (-not (Test-Path $Config.LogFile)) { New-Item -ItemType File -Path $Config.LogFile -Force | Out-Null } #endregion =================================================================== #region ============================ LOGGING ================================= function Rotate-Log { try { if (-not $Config.LogRotate) { return } $file = $Config.LogFile if (-not (Test-Path $file)) { return } $max = [long]($Config.LogMaxSizeMB * 1MB) $fi = Get-Item $file if ($fi.Length -lt $max) { # подчистка ротаций по возрасту/количеству $dir = Split-Path $file -Parent $base = [IO.Path]::GetFileNameWithoutExtension($file) # win-f2b $rot = Get-ChildItem $dir -Filter ($base + '_*.log') -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending if ($rot) { $cutoff = (Get-Date).AddDays(-[int]$Config.LogMaxAgeDays) $byAge = $rot | Where-Object { $_.LastWriteTime -lt $cutoff } $byCnt = $rot | Select-Object -Skip $Config.LogMaxFiles ($byAge + $byCnt | Select-Object -Unique) | ForEach-Object { Remove-Item $_.FullName -Force -ErrorAction SilentlyContinue } } return } # ротация $dir = Split-Path $file -Parent $base = [IO.Path]::GetFileNameWithoutExtension($file) # win-f2b $stamp = Get-Date -Format 'yyyyMMdd_HHmmss' $arch = Join-Path $dir ("{0}_{1}.log" -f $base,$stamp) Rename-Item -Path $file -NewName $arch -Force New-Item -ItemType File -Path $file -Force | Out-Null # чистка лишних после ротации $rot = Get-ChildItem $dir -Filter ($base + '_*.log') -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending if ($rot) { $cutoff = (Get-Date).AddDays(-[int]$Config.LogMaxAgeDays) $byAge = $rot | Where-Object { $_.LastWriteTime -lt $cutoff } $byCnt = $rot | Select-Object -Skip $Config.LogMaxFiles ($byAge + $byCnt | Select-Object -Unique) | ForEach-Object { Remove-Item $_.FullName -Force -ErrorAction SilentlyContinue } } } catch { } } function Write-Log([string]$msg) { $line = ('{0:yyyy-MM-ddTHH:mm:ss} {1}' -f (Get-Date), $msg) # локальное время Add-Content -Path $Config.LogFile -Value $line Rotate-Log } #endregion =================================================================== #region ============================ STATE =================================== function Load-State { Get-Content $Config.StateFile -Raw | ConvertFrom-Json } function Save-State($state) { ($state | ConvertTo-Json -Depth 6) | Set-Content -Path $Config.StateFile -Encoding UTF8 } $State = Load-State if (-not $State) { $State = [pscustomobject]@{} } # внешний whitelist (IP или CIDR в строке, # — комментарии) if ($Config.WhitelistFile -and (Test-Path $Config.WhitelistFile)) { try { $fileWL = Get-Content -Path $Config.WhitelistFile -ErrorAction Stop | Where-Object { $_ -and $_ -notmatch '^\s*#' } | ForEach-Object { $_.Trim() } | Where-Object { $_ } if ($fileWL.Count) { $Config.Whitelist += $fileWL } Write-Log "INFO loaded whitelist file: $($Config.WhitelistFile) items=$($fileWL.Count)" } catch { Write-Log "WARN failed to load whitelist file: $($Config.WhitelistFile) : $($_.Exception.Message)" } } #endregion =================================================================== #region ============================ HELPERS ================================= function Test-IPv4InCidr([string]$ip, [string]$cidr) { try { if ($cidr -notmatch '^\s*\d{1,3}(\.\d{1,3}){3}/\d{1,2}\s*$') { return $false } $cidr = $cidr.Trim() $parts = $cidr.Split('/') $maskBits = [int]$parts[1] if ($maskBits -lt 0 -or $maskBits -gt 32) { return $false } $toU32 = { param($s) $q=$s.Split('.'); if($q.Count -ne 4){return $null} foreach($o in $q){ if([int]$o -lt 0 -or [int]$o -gt 255){ return $null } } return ([uint32]$q[0] -shl 24) -bor ([uint32]$q[1] -shl 16) -bor ([uint32]$q[2] -shl 8) -bor ([uint32]$q[3]) } $network = & $toU32 $parts[0]; if($null -eq $network){return $false} $addr = & $toU32 $ip; if($null -eq $addr) {return $false} $mask = if ($maskBits -eq 0) { [uint32]0 } else { ([uint32]0xFFFFFFFF) -shl (32 - $maskBits) } return (($addr -band $mask) -eq ($network -band $mask)) } catch { return $false } } function Test-Whitelisted([string]$ip) { if (-not $ip) { return $false } foreach ($w in $Config.Whitelist) { if ([string]::IsNullOrWhiteSpace($w)) { continue } $w = $w.Trim() if ($w -match '/') { if (Test-IPv4InCidr $ip $w) { Write-Log "WHITELIST-MATCH $ip in $w"; return $true } } else { if ($ip -eq $w) { Write-Log "WHITELIST-MATCH $ip == $w"; return $true } } } return $false } function Get-IpFromEvent($evt) { try { $xml = [xml]$evt.ToXml() $node = $xml.Event.EventData.Data | Where-Object { $_.Name -eq 'IpAddress' } $ip = $node.'#text' if ([string]::IsNullOrWhiteSpace($ip) -or $ip -eq '-') { return $null } return $ip } catch { return $null } } function New-BlockRule([string]$ip, [datetime]$expires) { # СТАБИЛЬНОЕ имя и локальная дата в описании $name = '{0} {1}' -f $Config.RuleNamePrefix, $ip $desc = 'WinMiniFail2Ban; Expires={0:yyyy-MM-ddTHH:mm:ss}' -f $expires try { $r = Get-NetFirewallRule -DisplayName $name -ErrorAction SilentlyContinue if ($r) { Set-NetFirewallRule -DisplayName $name -Description $desc | Out-Null Write-Log "EXT $ip to $($expires.ToString('s'))" return $name } else { New-NetFirewallRule -DisplayName $name -Group $Config.RuleGroup -Direction Inbound -Action Block ` -RemoteAddress $ip -Profile $Config.Profiles -Description $desc -ErrorAction Stop | Out-Null Write-Log "BAN $ip until $($expires.ToString('s'))" return $name } } catch { Write-Log "ERR rule upsert for $ip : $($_.Exception.Message)" return $null } } function Remove-BlockRuleByName([string]$name) { try { Get-NetFirewallRule -DisplayName $name -ErrorAction Stop | Remove-NetFirewallRule -Confirm:$false -ErrorAction Stop Write-Log "UNBAN (rm) $name" } catch { Write-Log "WARN rule not found to remove: $name" } } function Cleanup-ExpiredRules { $now = Get-Date # локальное $changed = $false # 1) по state.json foreach ($ip in @($State.PSObject.Properties.Name)) { $entry = $State.$ip if ($entry -and $entry.expires) { $exp = [datetime]$entry.expires if ($exp -lt $now) { if ($entry.rule) { Remove-BlockRuleByName $entry.rule } $State.PSObject.Properties.Remove($ip) | Out-Null $changed = $true } } } # 2) страховка: убрать просроченные из группы (по Description: Expires=...) Get-NetFirewallRule -Group $Config.RuleGroup -ErrorAction SilentlyContinue | ForEach-Object { $r = $_ $desc = $r.Description if ($desc -match 'Expires=(?<ts>\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})') { $exp = [datetime]$Matches.ts if ($exp -lt $now) { Remove-NetFirewallRule -Name $r.Name -Confirm:$false } } } if ($changed) { Save-State $State } } #endregion =================================================================== #region ============================== CORE ================================== # Сбор событий за окно (XPath: по timediff, быстро) $ms = [int]$Config.ObservationMinutes * 60 * 1000 $xpathIds = ($Config.EventIDs | ForEach-Object { "EventID=$_"} ) -join ' or ' $xpath = "*[System[($xpathIds) and TimeCreated[timediff(@SystemTime) <= $ms]]]" $events = Get-WinEvent -LogName Security -FilterXPath $xpath -ErrorAction SilentlyContinue # Считаем по IP: количество и ВРЕМЯ САМОГО НОВОГО события (Last, с миллисекундами) $ipStats = @{} foreach ($e in $events) { $ip = Get-IpFromEvent $e if ($ip -and $ip -ne '::1' -and $ip -ne '127.0.0.1') { if (-not $ipStats.ContainsKey($ip)) { $ipStats[$ip] = [pscustomobject]@{ Count = 0; Last = $e.TimeCreated } } $ipStats[$ip].Count++ if ($e.TimeCreated -gt $ipStats[$ip].Last) { $ipStats[$ip].Last = $e.TimeCreated } } } $now = Get-Date $targets = $ipStats.GetEnumerator() | Where-Object { $_.Value.Count -ge $Config.BanThreshold } foreach ($t in $targets) { $ip = $t.Key $stat = $t.Value if (Test-Whitelisted $ip) { Write-Log "SKIP $ip (whitelisted)"; continue } $expires = $now.AddMinutes($Config.BanMinutes) if ($State.PSObject.Properties.Name -contains $ip) { # продлеваем ТОЛЬКО если появились новые события позже запомненного lastSeen $prevSeen = if ($State.$ip.lastSeen) { [datetime]$State.$ip.lastSeen } else { Get-Date '1900-01-01' } if ($Config.ExtendOnRepeat -and $stat.Last -gt $prevSeen) { $State.$ip.expires = $expires.ToString('yyyy-MM-ddTHH:mm:ss') $State.$ip.lastSeen = $stat.Last.ToString('o') # сохраняем с миллисекундами! $name = New-BlockRule -ip $ip -expires $expires # обновит Description или создаст if ($name) { $State.$ip.rule = $name; Save-State $State } } else { Write-Log "SKIP $ip no new attempts since $($prevSeen.ToString('o'))" } } else { # первый бан $name = New-BlockRule -ip $ip -expires $expires if ($name) { $obj = [pscustomobject]@{ rule = $name expires = $expires.ToString('yyyy-MM-ddTHH:mm:ss') lastSeen = $stat.Last.ToString('o') # сохраняем с миллисекундами! } Add-Member -InputObject $State -NotePropertyName $ip -NotePropertyValue $obj -Force Save-State $State } } } # Очистка просроченных Cleanup-ExpiredRules #endregion ===================================================================