Télécharger Sync-Blocklist.ps1

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

Fichiers prêts à adapter