Cloudflare IP List API: .NET 10'dan Toplu IP Blocklist Push

0 yorum 449 görüntülenme

Siber Güvenlik dotnet devops Cloudflare WAF API HttpClient

11 dk okuma 2108 kelime

TL;DR

Cloudflare Custom Rules free plan'da zone başına 5 kuralla sınırlı — her IP'yi ayrı kurala çevirmek iki blokta duvara toslar. Çözüm tek kural + bir Cloudflare List: liste başına 10.000 IP, hesap başına 10 liste, efektif 100.000 IP kapasite tek WAF slot'uyla. .NET 10 servisimiz otomatik blokladığı IP'leri fire-and-forget edge'e push ediyor, bulk endpoint'in operation_id vs item_id tuzağını çözüyor, TTL dolunca list item'ını siliyor. Tam prod kodu aşağıda.

Yazar notu: Bu yazıdaki kod ve şema bilalkose.com.tr canlı sisteminden alındı. ICloudflareIpListService + IpBlocklist tablosu + fire-and-forget push döngüsü 2026-05 itibarıyla prod'da çalışıyor; origin classifier otomatik blokladığı her IP'yi tek bir Cloudflare List'e yazıyor. Pillar 1 H2-4'te IP Blocklist push'unu yüzeysel geçmiştik; bu yazıda ölçeklenme, bulk endpoint tuzağı ve TTL temizliği detaylı.


İçindekiler

  1. Neden Cloudflare List? 5 kural limiti vs 100 bin IP kapasite
  2. ICloudflareIpListService: bootstrap + ekle + sil
  3. Toplu ekleme async: operation_id vs item_id tuzağı
  4. Otomatik blok döngüsü: fire-and-forget edge push
  5. Admin paneli: tek-tık ve toplu push
  6. TTL expirer: CloudflareListItemId neden saklanır
  7. Sıkça Sorulan Sorular
  8. İlgili Yazılar

on TTL expiry Origin Classifier catches the attack IpBlocklist (SQL) row written, 24h TTL Fire-and-forget background Task CF List Item added via Lists API Single WAF Rule blocks at the edge TTL Expirer removes row + list item Cloudflare IP List push — one rule, edge-side blocking, self-cleaning
Cloudflare IP List push akışı: origin classifier saldırı yakalar → IpBlocklist tablosu 24 saat TTL → fire-and-forget Task → CF List item ekle → tek WAF kuralı edge'de bloklar → TTL expirer dolunca DB ve list item silinir


Neden Cloudflare List? 5 Kural Limiti vs 100 Bin IP Kapasite

Pillar 1'de IP blocklist push'unu tek cümleyle geçmiştik: "şüpheli IP'yi Cloudflare'e gönder, edge'de dursun". Bu yazı o cümlenin altındaki ölçeklenme problemini açıyor.

İlk refleks her IP için ayrı bir Custom Rule yazmaktır — ip.src eq 1.2.3.4, action block. Birkaç IP sonra duvara toslarsın: Cloudflare free plan zone başına yalnızca 5 Custom Rule veriyor (Pro plan 25). 15 IP'lik bir blocklist için bu kategori dışı. Üstüne her IP'yi kurala çevirmek demek, her blok ve her expire'da Cloudflare API'sine bir rule CRUD çağrısı demek — token başına 5 dakikada 1200 request olan rate limit'i hızla yer.

Doğru yapı tek kural + bir Cloudflare List. List, hesap düzeyinde bir IP koleksiyonudur; WAF kuralının içinde ip.src in $bilalkose_auto_block ifadesiyle referanslanır. Tek liste 10.000 IP taşır, hesap başına 10 liste açılabilir — efektif 100.000 IP kapasite, tek WAF rule slot'uyla. 15 IP de 9.000 IP de aynı kuralla bloklanır; kural sayısı hiç değişmez.

Yaklaşım Kapasite (free plan) WAF rule slotu Güncelleme maliyeti
IP başına Custom Rule 5 IP 5/5 dolu Her IP = 1 rule CRUD çağrısı
Tek liste + tek kural 10.000 IP × 10 liste 1/5 List item ekle/sil, kural sabit

Bizim setup'ta tek liste ve onu referanslayan tek ip.src in $bilalkose_auto_block kuralı var. Origin classifier otomatik blokladığı her IP'yi bu listeye yazıyor; WAF kuralına bir daha hiç dokunulmuyor.


ICloudflareIpListService: Bootstrap + Ekle + Sil

Tüm CF List işlemleri tek arayüzden akıyor. B1'deki ICloudflareWafRulesService ile aynı tasarım dili: küçük, niyet açık, immutable döndüren:

public interface ICloudflareIpListService
{
    /// <summary>List ID — başlangıçta Initialize çağrılır, sonra bu property dolu olur.</summary>
    string? ListId { get; }

    bool IsReady { get; }

    /// <summary>Açılışta tek seferlik çağrı — list yoksa oluşturur.</summary>
    Task InitializeAsync(CancellationToken ct);

    /// <summary>Auto-block için: IP'yi Cloudflare list'ine ekle.
    /// Başarılıysa Cloudflare'in döndürdüğü item ID'yi döner (DB'de saklanır, silmek için lazım).</summary>
    Task<string?> AddBlockedIpAsync(string ip, string reason, CancellationToken ct);

    /// <summary>TTL expirer için: list item'ını sil.</summary>
    Task<bool> RemoveBlockedIpAsync(string itemId, CancellationToken ct);
}

Üç davranış noktası var. InitializeAsync açılışta listeyi bootstrap eder — yoksa oluşturur, idempotent. AddBlockedIpAsync bir blok ekler ve item ID döner. RemoveBlockedIpAsync o item ID ile siler. IsReady flag'i, token ya da list ID eksikse servisin sessizce no-op moduna düşmesini sağlar; Cloudflare entegrasyonu çökse bile origin classifier çalışmaya devam eder — edge push bir savunma katmanı, tek hat değil.

DI kaydı singleton:

// Typed HttpClient + Bearer auth (CloudflareApiClient ctor'unda set edilir)
services.AddHttpClient<CloudflareApiClient>();

// Singleton state (ListId, ZoneId cache) — uygulama ömrü boyunca paylaşılır
services.AddSingleton<ICloudflareZoneResolver, CloudflareZoneResolver>();
services.AddSingleton<ICloudflareIpListService, CloudflareIpListService>();

Servis singleton, çünkü ListId bir kez çözülüp uygulama ömrü boyunca cache'lenir — her istekte listeyi yeniden sorgulamak israf olur. Altındaki CloudflareApiClient typed bir HttpClient: BaseAddress, 30 saniye timeout ve Authorization: Bearer {token} header'ı ctor'da set edilir, token Vault'tan gelir.


Toplu Ekleme Async: operation_id vs item_id Tuzağı

Bu yazının en sinir bozucu detayı. Cloudflare'in list item ekleme endpoint'i — POST /accounts/{id}/rules/lists/{list}/itemsbulk ve asenkron. Tek IP göndersen bile body bir dizidir:

public async Task<string?> AddBlockedIpAsync(string ip, string reason, CancellationToken ct)
{
    if (!IsReady) return null;
    if (string.IsNullOrWhiteSpace(ip)) return null;

    var comment = Truncate(reason, CommentMaxLen);
    var body = new[] { new CfListItemRequest(ip, comment) };

    // POST = bulk operation; çoklu item de gönderilebilir, biz tek IP gönderiyoruz
    var resp = await _api.PostAsync<CfBulkOperation>(
        $"accounts/{_api.Options.AccountId}/rules/lists/{ListId}/items",
        body, ct).ConfigureAwait(false);

    if (resp?.Success != true)
    {
        LogCfErrors($"add-ip:{ip}", resp);
        CloudflareMetrics.IpListItemsAddedTotal.WithLabels("error").Inc();
        return null;
    }

Tuzak şu: bu endpoint eklediğin item'ın ID'sini dönmez. Bulk işlem olduğu için bir operation_id döner — yani "işlem kuyruğa alındı". Ama biz item ID'yi DB'de saklamak zorundayız; TTL dolduğunda o item'ı silmek için ID lazım, IP yetmiyor.

Çözüm, ekleme sonrası listeyi item için yeniden sorgulamak:

    // Cloudflare bulk endpoint async — operation_id döner. Item ID'yi almak için
    // listeyi yeniden sorgulamak veya GET items?search=ip kullanmak lazım.
    var search = await _api.GetAsync<List<CfListItem>>(
        $"accounts/{_api.Options.AccountId}/rules/lists/{ListId}/items?search={Uri.EscapeDataString(ip)}",
        ct).ConfigureAwait(false);

    if (search?.Success == true && search.Result is { Count: > 0 })
    {
        var item = search.Result.FirstOrDefault(i =>
            string.Equals(i.Ip, ip, StringComparison.OrdinalIgnoreCase));
        if (item is not null)
        {
            _logger.LogInformation("[CF IpList] Added {Ip} → item={ItemId}", ip, item.Id);
            CloudflareMetrics.IpListItemsAddedTotal.WithLabels("success").Inc();
            return item.Id;
        }
    }

    // Item ID alamadık ama ekleme başarılı görünüyor — operation_id'yi yedek olarak döndür.
    _logger.LogWarning("[CF IpList] Added {Ip} but could not resolve item id", ip);
    CloudflareMetrics.IpListItemsAddedTotal.WithLabels("success").Inc();
    return resp.Result?.OperationId;
}

İki adım: POST ile ekle, ardından GET items?search={ip} ile item'ı bul ve ID'sini döndür. Match'te OrdinalIgnoreCase kullanmak şart — IPv6 adresleri büyük/küçük harf normalize edilmeden gelebilir. Search bir match bulamazsa operation_id'yi yedek olarak döndürüyoruz; ID tam doğru olmasa da DB'deki CloudflareListItemId kolonu null kalmaz, böylece toplu push aynı IP'yi tekrar denemez. Inc() çağrıları Prometheus counter'ı besler — bilal_cloudflare_iplist_items_added_total{status} metriği Grafana'da ekleme ve hata oranını gösterir.

.NET Service Cloudflare List API IpBlocklist (SQL) 1. POST /lists/{id}/items — IP array 2. operation_id — item_id NOT returned 3. GET /lists/{id}/items?search=ip 4. real item_id resolved from the page 5. UPDATE IpBlocklist SET CloudflareListItemId = item_id The bulk endpoint is asynchronous — a second GET resolves the id the POST never gave back
Bulk endpoint iki adımlı çözüm: POST items dizi gönderir → Cloudflare operation_id döner → GET items?search=ip ikinci çağrısı gerçek item_id'yi resolve eder → item_id IpBlocklist.CloudflareListItemId kolonuna yazılır


Otomatik Blok Döngüsü: Fire-and-Forget Edge Push

Origin classifier bir saldırı probe'u yakalayıp tehdit eşiğini aştığında IP'yi dbo.IpBlocklist tablosuna yazar. Tablonun şeması:

CREATE TABLE dbo.IpBlocklist (
    RawIp                NVARCHAR(45)  NOT NULL,
    BlockedAt            DATETIME2(0)  NOT NULL CONSTRAINT DF_IpBlocklist_BlockedAt DEFAULT SYSUTCDATETIME(),
    ExpiresAt            DATETIME2(0)  NOT NULL,
    Reason               NVARCHAR(200) NOT NULL,
    ThreatScore          INT           NOT NULL,
    HitCount             INT           NOT NULL CONSTRAINT DF_IpBlocklist_HitCount DEFAULT 1,
    LastHitAt            DATETIME2(0)  NOT NULL CONSTRAINT DF_IpBlocklist_LastHitAt DEFAULT SYSUTCDATETIME(),
    CloudflareListItemId NVARCHAR(64)  NULL,
    CONSTRAINT PK_IpBlocklist PRIMARY KEY CLUSTERED (RawIp)
);

CloudflareListItemId kolonu kilit nokta — bir önceki bölümde döndürdüğümüz item ID burada saklanır. DB INSERT bittikten sonra Cloudflare push'u fire-and-forget tetiklenir; gelen isteğin 403 yanıtını Cloudflare API'sini bekleterek geciktirmek anlamsız:

    // Cloudflare edge-block — fire-and-forget, isteğin 403'ünü bekletme
    // Başarılı ise CloudflareListItemId DB'ye yazılır (TTL expirer silmek için kullanır)
    var ipCopy = Truncate(ip, 45) ?? ip;
    var reasonCopy = reason ?? string.Empty;
    _ = Task.Run(() => PushToCloudflareAsync(ipCopy, reasonCopy));
}

Task.Run çağrısı bir discard'a (_) atanır. PushToCloudflareAsync kendi DI scope'unu açar — singleton middleware'den scoped bir servise ulaşmanın doğru yolu — push eder ve dönen ID'yi DB'ye yazar:

private async Task PushToCloudflareAsync(string ip, string reason)
{
    try
    {
        using var scope = _serviceProvider.CreateScope();
        var cf = scope.ServiceProvider.GetService<ICloudflareIpListService>();
        if (cf is null || !cf.IsReady) return;

        var itemId = await cf.AddBlockedIpAsync(ip, reason, CancellationToken.None).ConfigureAwait(false);
        if (string.IsNullOrEmpty(itemId)) return;

        const string updateSql = @"
UPDATE dbo.IpBlocklist
SET CloudflareListItemId = @id
WHERE RawIp = @ip AND (CloudflareListItemId IS NULL OR CloudflareListItemId <> @id);";

        using var conn = new SqlConnection(_connectionString);
        await conn.OpenAsync().ConfigureAwait(false);
        using var cmd = new SqlCommand(updateSql, conn);
        cmd.Parameters.AddWithValue("@id", itemId);
        cmd.Parameters.AddWithValue("@ip", ip);
        await cmd.ExecuteNonQueryAsync().ConfigureAwait(false);
    }
    catch (Exception ex)
    {
        _logger.Error($"[TrafficClassifier] CF list push failed for {ip}: {ex.Message}");
    }
}

Tüm method try/catch ile sarılı: CF push başarısız olsa bile IP zaten IpBlocklist'te — origin classifier sonraki isteği 60 saniyelik cache lookup'ıyla 403 yapar. Edge push bir optimizasyon, tek savunma hattı değil. İlk istek 24 saatlik pencere içinde origin'e bir kez ulaşır; edge push başarılıysa sonraki istekler Cloudflare'de durur ve origin'i hiç görmez.


Admin Paneli: Tek-Tık ve Toplu Push

Otomatik döngü çoğu işi halleder, ama iki senaryoda manuel push lazım olur: (1) CF entegrasyonu bir süre kapalıyken biriken kayıtlar, (2) elle eklenen bir IP. /admin/security/ip-blocklist sayfasında iki action bunun için var.

Tek IP push:

[HttpPost("{ip}/push-cf")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> PushCf(string ip, CancellationToken ct)
{
    if (!IsValidIp(ip))
    {
        SetErrorMessage("Geçersiz IP adresi.");
        return RedirectToAction(nameof(Index));
    }
    if (_cf is null || !_cf.IsReady)
    {
        SetErrorMessage("Cloudflare entegrasyonu hazır değil (token / list).");
        return RedirectToAction(nameof(Index));
    }

    var actor = User.Identity?.Name ?? "admin";
    try
    {
        var itemId = await _cf.AddBlockedIpAsync(ip, $"admin_push by {actor}", ct);
        if (string.IsNullOrEmpty(itemId))
        {
            SetErrorMessage($"{ip} Cloudflare push başarısız (item id dönmedi).");
            return RedirectToAction(nameof(Index));
        }
        await UpdateCfItemIdAsync(ip, itemId, ct);
        SetSuccessMessage($"{ip} Cloudflare list'e eklendi.");
    }
    catch (Exception ex)
    {
        _logger.Error($"[IpBlocklistAdmin] PushCf failed for {ip}: {ex.Message}");
        SetErrorMessage($"{ip} push hatası: {ex.Message}");
    }
    return RedirectToAction(nameof(Index));
}

Toplu push, CF'de henüz olmayan kayıtları tek seferde gönderir — CloudflareListItemId IS NULL filtresi anahtar:

// CF'de henüz olmayan + henüz expire olmamış kayıtları çek
const string selectSql = @"
SELECT RawIp FROM dbo.IpBlocklist
WHERE ExpiresAt > SYSUTCDATETIME() AND CloudflareListItemId IS NULL;";

ExpiresAt > SYSUTCDATETIME() süresi dolmuş kayıtları atlar, CloudflareListItemId IS NULL zaten push edilmişleri atlar — yani toplu push idempotent. İki kez bassan ikinci sefer "push edilecek kayıt yok" döner. foreach döngüsü her IP için AddBlockedIpAsync + DB UPDATE çağırır, pushed ve failed sayar; tek bir IP patlasa döngü kalanlarla devam eder.


TTL Expirer: CloudflareListItemId Neden Saklanır

Otomatik bloklar kalıcı değildir — IpBlocklist.ExpiresAt 24 saatlik bir TTL taşır. Saatte bir koşan IpBlocklistExpiryHostedService, ExpiresAt < NOW olan kayıtları işler: DB'den DELETE eder ve Cloudflare list item'ını siler.

İşte CloudflareListItemId'yi saklamanın tek sebebi bu. Cloudflare'in list item silme endpoint'i IP'yi değil, item ID'sini ister:

public async Task<bool> RemoveBlockedIpAsync(string itemId, CancellationToken ct)
{
    if (!IsReady) return false;
    if (string.IsNullOrWhiteSpace(itemId)) return false;

    var body = new { items = new[] { new { id = itemId } } };
    var resp = await _api.DeleteAsync<CfBulkOperation>(
        $"accounts/{_api.Options.AccountId}/rules/lists/{ListId}/items",
        body, ct).ConfigureAwait(false);

    if (resp?.Success == true)
    {
        _logger.LogInformation("[CF IpList] Removed item {ItemId}", itemId);
        CloudflareMetrics.IpListItemsRemovedTotal.WithLabels("success").Inc();
        return true;
    }

    LogCfErrors($"remove-item:{itemId}", resp);
    CloudflareMetrics.IpListItemsRemovedTotal.WithLabels("error").Inc();
    return false;
}

Toplu ekleme bölümünde item ID'yi resolve etmek için fazladan bir GET attığımızı hatırla — bedeli burada ödenir. ID'yi push anında yakalamasaydık, expirer hangi item'ı sileceğini bilemez ve Cloudflare listesi sonsuza dek şişerdi: bloklar 24 saatte origin'de expire olur ama edge'de sonsuza dek kalırdı. search başarısız olup operation_id yedeğine düştüğümüz nadir durumda silme başarısız olur; bu kayıtlar CF tarafında biraz fazladan kalır — zararsız, çünkü IP zaten origin'de expire olmuştur ve edge'de fazladan blok yalnızca daha sıkı tarafa düşer.


Sıkça Sorulan Sorular

Cloudflare List free plan'da kullanılabilir mi?

Evet. Account-level Lists free plan'da var: liste başına 10.000 item, hesap başına 10 liste. WAF tarafında listeyi referanslayan kural, free plan'ın 5 Custom Rule kotasından yalnızca bir slot yer. Yani 100.000 IP'lik kapasite tek slotla free plan'da mümkün.

IP rule yerine doğrudan List kullanmanın dezavantajı ne?

Listedeki item'lar tek tek farklı action ya da log alamaz — listedeki her IP, onu referanslayan kuralın action'ını paylaşır. Farklı IP gruplarına farklı davranış (biri block, biri challenge) gerekiyorsa ayrı listeler ve ayrı kurallar açarsın. Bizim ihtiyaç tek aksiyon — block — olduğu için tek liste yetiyor.

Bulk endpoint async ise eklediğim IP ne zaman aktif olur?

operation_id döndükten sonra item genelde saniyeler içinde listede görünür; WAF kuralının onu yakalaması worldwide propagation ile birlikte 30 saniyenin altında. Tek IP eklediğimiz için kuyruk neredeyse anında işlenir — bu yüzden ekleme sonrası attığımız search çağrısı item'ı zaten görür.

Fire-and-forget push hata verirse IP bloksuz mu kalır?

Hayır. IP zaten dbo.IpBlocklist'te; origin classifier sonraki isteği DB/cache lookup'ıyla 403 yapar. Edge push yalnızca origin'i tasarruf eden bir optimizasyon. Push hatası log'lanır, admin panelindeki toplu push action'ı CloudflareListItemId IS NULL kalan kayıtları sonradan toparlar.


İlgili Yazılar

Yorumlar (0)

Yorum ve puan bırakın

Henüz yorum yapılmamış. İlk yorumu sen bırak.