#requires -RunAsAdministrator [CmdletBinding()] param( [string]$BaseUrl = "https://blocklist.example.net", [string]$Slug = "all", [string]$StateDir = "$env:ProgramData\BlocklistSync", [int]$ChunkSize = 500 ) $ErrorActionPreference = "Stop" $BaseUrl = $BaseUrl.TrimEnd('/') if (-not $BaseUrl.StartsWith('https://')) { throw 'BaseUrl must use HTTPS' } if ($Slug -notmatch '^[a-z0-9]+(?:-[a-z0-9]+)*$') { throw 'Invalid slug' } if ($ChunkSize -lt 1 -or $ChunkSize -gt 2000) { throw 'ChunkSize must be between 1 and 2000' } New-Item -ItemType Directory -Path $StateDir -Force | Out-Null $StatePath = Join-Path $StateDir "$Slug-state.json" $Cache4 = Join-Path $StateDir "$Slug-ipv4.txt" $Cache6 = Join-Path $StateDir "$Slug-ipv6.txt" function Read-State { if (Test-Path $StatePath) { try { return Get-Content -Raw -LiteralPath $StatePath | ConvertFrom-Json } catch { return [pscustomobject]@{} } } return [pscustomobject]@{} } function Test-NetworkLine([string]$Line, [int]$Family) { if ([string]::IsNullOrWhiteSpace($Line) -or $Line -ne $Line.Trim() -or $Line.StartsWith('#')) { return $false } $parts = $Line.Split('/', 2) $ip = $null if (-not [System.Net.IPAddress]::TryParse($parts[0], [ref]$ip)) { return $false } if ($Family -eq 4 -and $ip.AddressFamily -ne [System.Net.Sockets.AddressFamily]::InterNetwork) { return $false } if ($Family -eq 6 -and $ip.AddressFamily -ne [System.Net.Sockets.AddressFamily]::InterNetworkV6) { return $false } if ($parts.Count -eq 2) { $prefix = 0 if (-not [int]::TryParse($parts[1], [ref]$prefix)) { return $false } if ($Family -eq 4 -and ($prefix -lt 0 -or $prefix -gt 32)) { return $false } if ($Family -eq 6 -and ($prefix -lt 0 -or $prefix -gt 128)) { return $false } } return $true } function Validate-List([string]$Text, [int]$Family) { if ($Text.Length -gt 8MB) { throw "IPv$Family list exceeds local safety limit" } $lines = @() foreach ($line in ($Text -split "`r?`n")) { if ($line -eq '') { continue } # permits the final LF and a completely empty publication if (-not (Test-NetworkLine $line $Family)) { throw "Invalid IPv$Family line: $line" } $lines += $line if ($lines.Count -gt 100000) { throw "IPv$Family list contains too many entries" } } return @($lines | Select-Object -Unique) } function Fetch-List([int]$Family, [string]$CachePath, $State) { $suffix = if ($Family -eq 4) { 'ipv4.txt' } else { 'ipv6.txt' } $uri = "$BaseUrl/blocklists/$Slug/$suffix" $client = [System.Net.Http.HttpClient]::new() try { $request = [System.Net.Http.HttpRequestMessage]::new([System.Net.Http.HttpMethod]::Get, $uri) $request.Headers.UserAgent.ParseAdd('BlocklistSync-Windows/1.0') $etagProperty = "etag$Family" $etag = $State.$etagProperty if ($etag) { [void]$request.Headers.TryAddWithoutValidation('If-None-Match', [string]$etag) } $response = $client.SendAsync($request).GetAwaiter().GetResult() $code = [int]$response.StatusCode if ($code -eq 304) { if (-not (Test-Path $CachePath)) { throw "HTTP 304 for IPv$Family but local cache is missing" } $text = Get-Content -Raw -LiteralPath $CachePath return [pscustomobject]@{ Lines = (Validate-List $text $Family); ETag = $etag; Changed = $false } } if ($code -eq 404 -or $code -eq 503) { throw "Upstream HTTP $code for IPv$Family; existing firewall rules are kept" } if ($code -ne 200) { throw "Unexpected upstream HTTP $code for IPv$Family" } $media = $response.Content.Headers.ContentType.MediaType if ($media -ne 'text/plain') { throw "Unexpected Content-Type $media for IPv$Family" } $text = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult() $lines = Validate-List $text $Family $tmp = "$CachePath.new" [System.IO.File]::WriteAllText($tmp, $text, [System.Text.UTF8Encoding]::new($false)) Move-Item -Force -LiteralPath $tmp -Destination $CachePath $newEtag = if ($response.Headers.ETag) { $response.Headers.ETag.ToString() } else { $null } return [pscustomobject]@{ Lines = $lines; ETag = $newEtag; Changed = $true } } finally { $client.Dispose() } } function Get-Sha12([string[]]$V4, [string[]]$V6) { $joined = "v4`n" + ($V4 -join "`n") + "`nv6`n" + ($V6 -join "`n") $bytes = [System.Text.Encoding]::UTF8.GetBytes($joined) $sha = [System.Security.Cryptography.SHA256]::Create() try { return (($sha.ComputeHash($bytes) | ForEach-Object ToString x2) -join '').Substring(0,12) } finally { $sha.Dispose() } } function Add-ChunkRules([string[]]$Addresses, [string]$FamilyLabel, [string]$Version, [System.Collections.Generic.List[string]]$Created) { for ($i = 0; $i -lt $Addresses.Count; $i += $ChunkSize) { $last = [Math]::Min($i + $ChunkSize - 1, $Addresses.Count - 1) $chunk = @($Addresses[$i..$last]) $index = [int]($i / $ChunkSize) $name = "BlocklistSync-$Slug-$Version-$FamilyLabel-$index" New-NetFirewallRule -Name $name -DisplayName $name -Description "IP Blocklist Manager Phase 19 slug=$Slug version=$Version" -Direction Inbound -Action Block -Profile Any -RemoteAddress $chunk | Out-Null $Created.Add($name) } } $state = Read-State $v4 = Fetch-List 4 $Cache4 $state $v6 = Fetch-List 6 $Cache6 $state if (-not $v4.Changed -and -not $v6.Changed) { Write-Host "Publication unchanged; firewall not modified." exit 0 } $version = Get-Sha12 $v4.Lines $v6.Lines $newPrefix = "BlocklistSync-$Slug-$version-" $created = [System.Collections.Generic.List[string]]::new() try { if ($v4.Lines.Count -gt 0) { Add-ChunkRules $v4.Lines 'v4' $version $created } if ($v6.Lines.Count -gt 0) { Add-ChunkRules $v6.Lines 'v6' $version $created } # Only after the full new ruleset exists do we remove the previous version. Get-NetFirewallRule | Where-Object { $_.Name -like "BlocklistSync-$Slug-*" -and $_.Name -notlike "$newPrefix*" } | Remove-NetFirewallRule $newState = [ordered]@{ etag4 = $v4.ETag; etag6 = $v6.ETag; version = $version; updatedUtc = [DateTime]::UtcNow.ToString('o') } $newState | ConvertTo-Json | Set-Content -Encoding UTF8 -LiteralPath $StatePath Write-Host "Applied slug=$Slug version=$version IPv4=$($v4.Lines.Count) IPv6=$($v6.Lines.Count)" } catch { foreach ($name in $created) { Get-NetFirewallRule -Name $name -ErrorAction SilentlyContinue | Remove-NetFirewallRule -ErrorAction SilentlyContinue } throw }