Fail2ban Windows PowerShell

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

Оставьте комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Прокрутить вверх