Cloudflare IP List API: دفع قوائم حظر IP المجمّعة من .NET 10

0 تعليق 450 مشاهدة

الأمن السيبراني dotnet devops Cloudflare WAF API HttpClient

12 دقيقة قراءة 2399 كلمة

TL;DR

قواعد Cloudflare Custom Rules محدودة بـ 5 قواعد لكل zone في الخطة المجانية، لذا يصطدم تحويل كل IP إلى قاعدة منفصلة بالجدار بعد حظرين. الحل قاعدة واحدة مع Cloudflare List واحدة: 10.000 IP لكل قائمة، 10 قوائم لكل حساب، سعة فعلية 100.000 IP من خانة WAF واحدة. خدمة .NET 10 لدينا تدفع كل IP محظور تلقائياً إلى الـ edge بنمط fire-and-forget (أطلق وانسَ)، إذ تتجاوز فخ operation_id مقابل item_id في الـ bulk endpoint، وتحذف عنصر القائمة عند انتهاء الـ TTL. كود الإنتاج الكامل أدناه.

ملاحظة الكاتب: الكود والمخطط في هذه المقالة مأخوذان من نظام bilalkose.com.tr الحي. واجهة ICloudflareIpListService وجدول IpBlocklist وحلقة دفع fire-and-forget تعمل في الإنتاج منذ مايو 2026، إذ يكتب الـ origin classifier كل IP يحظره تلقائياً إلى Cloudflare List واحدة. تناول Pillar 1 دفع قائمة حظر IP بإيجاز فقط، فيما هذه المقالة تتعمق في التوسّع وفخ الـ bulk endpoint وتنظيف الـ TTL.


جدول المحتويات

  1. لماذا Cloudflare List؟ حد الـ 5 قواعد مقابل سعة 100 ألف IP
  2. ICloudflareIpListService: التهيئة والإضافة والحذف
  3. الإضافة المجمّعة غير المتزامنة: operation_id مقابل item_id
  4. حلقة الحظر التلقائي: دفع edge بنمط fire-and-forget
  5. لوحة الإدارة: دفع بنقرة واحدة ودفع مجمّع
  6. مُنهي الـ TTL: لماذا نخزّن CloudflareListItemId
  7. الأسئلة الشائعة
  8. مقالات ذات صلة

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: الـ origin classifier يكتشف هجوماً، يكتب إلى جدول IpBlocklist بمهلة TTL مدتها 24 ساعة، يطلق مهمة خلفية، يضيف عنصر CF List، قاعدة WAF واحدة تحظره على الـ edge، ومُنهي الـ TTL يحذف السجل وعنصر القائمة عند الانتهاء


لماذا Cloudflare List؟ حد الـ 5 قواعد مقابل سعة 100 ألف IP

تناول Pillar 1 دفع قائمة حظر IP في جملة واحدة: أرسل الـ IP المشبوه إلى Cloudflare ودَعِ الـ edge يوقفه. هذه المقالة تفتح مشكلة التوسّع المختبئة تحت تلك الجملة.

الانعكاس الأول هو كتابة Custom Rule واحدة لكل IP، أي ip.src eq 1.2.3.4 مع الإجراء block. بعد عدة عناوين IP تصطدم بالجدار، لأنّ الخطة المجانية من Cloudflare تمنحك 5 قواعد Custom Rules فقط لكل zone (و25 في خطة Pro). بالنسبة لقائمة حظر من 15 IP فهذا خارج النطاق. وفوق ذلك، تحويل كل IP إلى قاعدة يعني استدعاء CRUD لقاعدة على Cloudflare API عند كل حظر وكل انتهاء صلاحية، ما يستهلك بسرعة حد المعدّل البالغ 1200 طلب لكل 5 دقائق لكل token.

البنية الصحيحة قاعدة واحدة مع Cloudflare List واحدة. الـ List مجموعة عناوين IP على مستوى الحساب، يُشار إليها داخل قاعدة WAF بالتعبير ip.src in $bilalkose_auto_block. القائمة الواحدة تحمل 10.000 IP، ويمكن فتح 10 قوائم لكل حساب، أي سعة فعلية 100.000 IP من خانة قاعدة WAF واحدة. سواء كان لديك 15 IP أو 9.000، تحظرها القاعدة نفسها جميعاً، إذ لا يتغير عدد القواعد أبداً.

في إعدادنا قائمة واحدة وقاعدة ip.src in $bilalkose_auto_block واحدة تشير إليها. يكتب الـ origin classifier كل IP يحظره تلقائياً إلى هذه القائمة، ولا تُلمس قاعدة WAF مرة أخرى أبداً.

المقاربة السعة (الخطة المجانية) خانات قاعدة WAF تكلفة التحديث
Custom Rule لكل IP 5 IP 5/5 ممتلئة كل IP = استدعاء CRUD واحد
قائمة واحدة + قاعدة واحدة 10.000 IP × 10 قوائم 1/5 إضافة/حذف عنصر قائمة، القاعدة ثابتة

ICloudflareIpListService: التهيئة والإضافة والحذف

كيف تبدو الواجهة التي تنظّم كل ذلك من جانب .NET؟ تتدفق كل عمليات CF List عبر واجهة واحدة، تحمل لغة التصميم نفسها التي في ICloudflareWafRulesService من B1: صغيرة، واضحة النية، تُرجِع أنواعاً غير قابلة للتغيير:

public interface ICloudflareIpListService
{
    /// <summary>List ID — populated after InitializeAsync runs at startup.</summary>
    string? ListId { get; }

    bool IsReady { get; }

    /// <summary>One-time startup call — creates the list if it does not exist.</summary>
    Task InitializeAsync(CancellationToken ct);

    /// <summary>For auto-block: add an IP to the Cloudflare list.
    /// On success, returns the item ID Cloudflare assigned (stored in the DB, needed to delete it later).</summary>
    Task<string?> AddBlockedIpAsync(string ip, string reason, CancellationToken ct);

    /// <summary>For the TTL expirer: delete a list item.</summary>
    Task<bool> RemoveBlockedIpAsync(string itemId, CancellationToken ct);
}

هناك ثلاث نقاط سلوكية. تُهيّئ InitializeAsync القائمة عند بدء التشغيل، فتُنشئها إن كانت مفقودة بشكل idempotent. تضيف AddBlockedIpAsync حظراً وتُرجِع item ID. تحذف RemoveBlockedIpAsync بذلك الـ item ID. تتيح راية IsReady للخدمة أن تسقط بهدوء إلى وضع no-op حين يكون الـ token أو الـ list ID مفقوداً، فيستمر الـ origin classifier في العمل حتى لو تعطّل تكامل Cloudflare. أرى أن دفع الـ edge ينبغي أن يكون طبقة دفاع، لا الخط الوحيد.

تسجيل DI من نوع singleton:

// Typed HttpClient + Bearer auth (set in the CloudflareApiClient constructor)
services.AddHttpClient<CloudflareApiClient>();

// Singleton state (ListId, ZoneId cache) — shared across the app lifetime
services.AddSingleton<ICloudflareZoneResolver, CloudflareZoneResolver>();
services.AddSingleton<ICloudflareIpListService, CloudflareIpListService>();

الخدمة من نوع singleton لأنّ ListId يُحَلّ مرة واحدة ويُخزّن مؤقتاً طوال عمر التطبيق، إذ إنّ إعادة الاستعلام عن القائمة في كل طلب تبديد. الـ CloudflareApiClient تحته هو HttpClient من النوع typed، يضبط في الـ constructor عنوانه الأساسي ومهلته البالغة 30 ثانية وترويسة Authorization: Bearer {token}، فيما يأتي الـ token من Vault.


الإضافة المجمّعة غير المتزامنة: operation_id مقابل item_id

أكثر تفاصيل هذه المقالة إثارةً للإحباط. نقطة نهاية إضافة عنصر القائمة في Cloudflare، وهي POST /accounts/{id}/rules/lists/{list}/items، مجمّعة وغير متزامنة معاً. حتى لو أرسلت IP واحداً، يكون الـ body مصفوفة:

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 is a bulk operation; it accepts many items, we send one IP
    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;
    }

الفخ هو هذا: لا تُرجِع هذه النقطة معرّف العنصر الذي أضفته. لأنّها عملية مجمّعة، تُرجِع operation_id فقط، أي أنّ المهمة دخلت الطابور. لكننا مضطرون لتخزين الـ item ID في قاعدة البيانات، إذ نحتاج ذلك الـ ID عند انتهاء الـ TTL لحذف العنصر، والـ IP وحده لا يكفي. الحل أن نستعلم عن العنصر في القائمة مباشرةً بعد إضافته:

    // The bulk endpoint is async — it returns an operation_id. To get the
    // item ID, query the list again, or use GET items?search=ip.
    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;
        }
    }

    _logger.LogWarning("[CF IpList] Added {Ip} but could not resolve item id", ip);
    CloudflareMetrics.IpListItemsAddedTotal.WithLabels("success").Inc();
    return resp.Result?.OperationId;
}

خطوتان: أضف بالـ POST، ثم اعثر على العنصر بـ GET items?search={ip} وأرجِع معرّفه. يجب أن تستخدم المطابقة OrdinalIgnoreCase، لأنّ عناوين IPv6 قد تعود دون تطبيع حالة الأحرف. إذا لم يجد البحث تطابقاً نُرجِع operation_id كحل احتياطي. قد لا يكون المعرّف دقيقاً تماماً، لكن عمود CloudflareListItemId في قاعدة البيانات لا يبقى null، فلا يُعيد الدفع المجمّع محاولة الـ IP نفسه. تغذّي استدعاءات Inc() عدّاد Prometheus، إذ يُظهر مقياس bilal_cloudflare_iplist_items_added_total{status} معدّل الإضافة والخطأ في Grafana.

.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: الـ POST يرسل مصفوفة، Cloudflare يُرجِع operation_id، واستدعاء GET items?search=ip ثانٍ يحلّ الـ item_id الحقيقي، الذي يُكتب بعدها إلى عمود IpBlocklist.CloudflareListItemId


حلقة الحظر التلقائي: دفع edge بنمط fire-and-forget

خلال نافذة 24 ساعة، يصل كل IP يكتبه الـ classifier إلى Cloudflare List في أقل من 30 ثانية، فيما تبقى مهلة الـ TTL على جانب الـ origin هي نقطة التحقق الأخيرة. حين يلتقط الـ origin classifier محاولة هجوم ويتجاوز عتبة التهديد، يكتب الـ IP إلى جدول dbo.IpBlocklist. مخطط الجدول:

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 هو نقطة الارتكاز، إذ يخزّن الـ item ID الذي أرجعناه في القسم السابق. بعد انتهاء عملية الـ INSERT في قاعدة البيانات، يُطلَق دفع Cloudflare بنمط fire-and-forget، لأنّ إبقاء الزائر منتظراً Cloudflare API قبل أن يحصل على رد 403 بلا معنى:

    // Cloudflare edge-block — fire-and-forget, do not delay the request's 403.
    var ipCopy = Truncate(ip, 45) ?? ip;
    var reasonCopy = reason ?? string.Empty;
    _ = Task.Run(() => PushToCloudflareAsync(ipCopy, reasonCopy));
}

يُسنَد استدعاء Task.Run إلى discard. تفتح PushToCloudflareAsync نطاق DI خاصاً بها (وهي الطريقة الصحيحة للوصول إلى خدمة scoped من middleware من نوع singleton)، ثم تدفع وتكتب الـ ID العائد إلى قاعدة البيانات:

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}");
    }
}

الدالة كلها ملفوفة بـ try/catch. حتى لو فشل دفع CF، فالـ IP موجود أصلاً في IpBlocklist، إذ يخدم الـ origin classifier الطلب التالي برد 403 من بحث ذاكرة مؤقتة مدته 60 ثانية. يبدو أن دفع الـ edge ينبغي أن يُعدّ تحسيناً لا خط دفاع وحيداً. يصل الطلب الأول إلى الـ origin مرة واحدة ضمن نافذة الـ 24 ساعة، وإن نجح دفع الـ edge، يتوقف كل طلب بعده عند Cloudflare ولا يرى الـ origin أبداً.


لوحة الإدارة: دفع بنقرة واحدة ودفع مجمّع

«كم سجلاً تراكم بينما كان التكامل متوقفاً؟» سؤال متكرر بعد أي حادث تشغيلي. تتولى الحلقة التلقائية معظم العمل، غير أنّه يلزم دفع يدوي في حالتين: السجلات التي تراكمت بينما كان تكامل CF متوقفاً، وIP أُضيف يدوياً. في صفحة /admin/security/ip-blocklist إجراءان لهذا تحديداً.

دفع IP واحد:

[HttpPost("{ip}/push-cf")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> PushCf(string ip, CancellationToken ct)
{
    if (!IsValidIp(ip))
    {
        SetErrorMessage("Invalid IP address.");
        return RedirectToAction(nameof(Index));
    }
    if (_cf is null || !_cf.IsReady)
    {
        SetErrorMessage("Cloudflare integration is not ready (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 failed (no item id returned).");
            return RedirectToAction(nameof(Index));
        }
        await UpdateCfItemIdAsync(ip, itemId, ct);
        SetSuccessMessage($"{ip} added to the Cloudflare list.");
    }
    catch (Exception ex)
    {
        _logger.Error($"[IpBlocklistAdmin] PushCf failed for {ip}: {ex.Message}");
        SetErrorMessage($"{ip} push error: {ex.Message}");
    }
    return RedirectToAction(nameof(Index));
}

يرسل الدفع المجمّع السجلات غير الموجودة بعد على Cloudflare دفعةً واحدة، إذ يُعدّ مرشّح CloudflareListItemId IS NULL هو المفتاح:

-- Pull records not yet on CF and not yet expired
const string selectSql = @"
SELECT RawIp FROM dbo.IpBlocklist
WHERE ExpiresAt > SYSUTCDATETIME() AND CloudflareListItemId IS NULL;";

يتخطّى ExpiresAt > SYSUTCDATETIME() السجلات المنتهية، فيما يتخطّى CloudflareListItemId IS NULL ما دُفع سابقاً، ما يجعل الدفع المجمّع idempotent. اضغطه مرتين فيُرجِع التشغيل الثاني «لا سجلات للدفع». تستدعي حلقة الـ foreach الدالة AddBlockedIpAsync مع تحديث لقاعدة البيانات لكل IP، وتعدّ الناجح والفاشل، وإن انفجر IP واحد، تتابع الحلقة مع البقية.


مُنهي الـ TTL: لماذا نخزّن CloudflareListItemId

مهلة 24 ساعة. هذه هي المسافة التي يقطعها كل حظر تلقائي قبل أن يُحذف من القاعدة ومن الـ edge معاً. الحظر التلقائي ليس دائماً، إذ يحمل IpBlocklist.ExpiresAt مهلة TTL مدتها 24 ساعة. تعالج خدمة IpBlocklistExpiryHostedService، التي تعمل مرة كل ساعة، السجلات التي ExpiresAt < NOW: تحذفها من قاعدة البيانات وتزيل عنصر Cloudflare list.

هذا هو السبب الوحيد لتخزين CloudflareListItemId. نقطة نهاية حذف عنصر القائمة في Cloudflare تطلب الـ item ID، لا الـ IP:

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

تذكّر استدعاء الـ GET الإضافي الذي أطلقناه في قسم الإضافة المجمّعة لحلّ الـ item ID، فهنا يُدفع ثمنه. لو لم نلتقط الـ ID وقت الدفع، لما عرف المُنهي أيّ عنصر يحذف، ولتضخّمت قائمة Cloudflare بلا حدود: ينتهي الحظر على الـ origin بعد 24 ساعة لكنه يبقى على الـ edge إلى الأبد. وفي الحالة النادرة التي يفشل فيها البحث ونسقط إلى احتياطي operation_id، يفشل الحذف أيضاً، فتبقى تلك السجلات مدةً أطول قليلاً على جانب CF، وهذا غير ضار، لأنّ الـ IP انتهى أصلاً على الـ origin، والحظر الزائد على الـ edge يميل إلى الجانب الأكثر صرامةً فقط.


الأسئلة الشائعة

هل Cloudflare List متاحة في الخطة المجانية؟

نعم. القوائم على مستوى الحساب متاحة في الخطة المجانية: 10.000 عنصر لكل قائمة، 10 قوائم لكل حساب. على جانب WAF، تأخذ القاعدة التي تشير إلى القائمة خانةً واحدةً فقط من حصة الـ 5 قواعد في الخطة المجانية. أي أنّ سعة 100.000 IP من خانة واحدة ممكنة في الخطة المجانية.

ما عيب استخدام List بدلاً من قواعد لكل IP؟

عناصر القائمة لا تستطيع كلٌّ منها أخذ إجراء أو إعداد log مختلف، إذ يشارك كل IP في القائمة إجراء القاعدة التي تشير إليها. إذا احتجت سلوكاً مختلفاً لمجموعات IP مختلفة (حظر مجموعة، تحدٍّ لأخرى)، تفتح قوائم منفصلة وقواعد منفصلة. حاجتنا إجراء واحد (block) فتكفي قائمة واحدة.

إن كانت الـ bulk endpoint غير متزامنة، متى يصبح الـ IP المُضاف فاعلاً؟

بعد عودة operation_id، يظهر العنصر عادةً في القائمة خلال ثوانٍ، وتلتقطه قاعدة WAF خلال أقل من 30 ثانية مع اكتمال الانتشار العالمي. لأنّنا نضيف IP واحداً، يُعالَج الطابور فوراً تقريباً.

إن فشل دفع fire-and-forget، هل يبقى الـ IP بلا حظر؟

لا. الـ IP موجود أصلاً في dbo.IpBlocklist، فيخدم الـ origin classifier الطلب التالي برد 403 من بحث قاعدة بيانات أو ذاكرة مؤقتة. يُعدّ دفع الـ edge مجرد تحسين يوفّر على الـ origin. يُسجَّل خطأ الدفع، ويجمع إجراء الدفع المجمّع في لوحة الإدارة السجلات المتبقية CloudflareListItemId IS NULL لاحقاً.


مقالات ذات صلة

التعليقات (0)

اترك تعليقاً وتقييماً

لا توجد تعليقات بعد. كن أول من يعلق.