#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 ===================================================================