Docker يتجاوز UFW: السبب وطريقة الإصلاح
ينشر Docker منافذ الحاويات بقواعد iptables تتجاوز UFW، فيبقى المنفذ المرفوض مستجيبًا للإنترنت. تعرّف على الآلية والحلول الفعّالة التي تعمل فعلًا.
لماذا يتجاوز Docker جدار UFW
يتجاوز Docker جدار UFW لأن منافذ الحاويات المنشورة لا تمر أبدًا عبر قواعد جدار الحماية التي يديرها UFW. عندما تنفّذ docker run -p 8080:80، يكتب Docker قاعدة DNAT (ترجمة عنوان الشبكة للوجهة، أو destination network address translation) داخل سلسلة PREROUTING في جدول nat الخاص بالنواة. تعيد هذه القاعدة كتابة وجهة كل حزمة إلى العنوان الخاص بالحاوية قبل أن تقرر النواة إلى أين تذهب الحزمة. وبعد إعادة الكتابة، تُحوَّل الحزمة إلى الحاوية عبر سلسلة FORWARD، التي يتحكم بها Docker. أما قواعد UFW فتقع في سلسلة INPUT، ولا تدخلها الحزمة أبدًا. لذلك يعرض ufw status رفضًا افتراضيًا، ويبلّغ sudo ufw deny 8080 عن نجاح، ويظل المنفذ 8080 مستجيبًا لكل الإنترنت.
هذا ليس عيبًا في Docker، وUFW ليس معطلًا. فكلتا الأداتين تبرمجان جدار الحماية نفسه في النواة. غير أن قواعد Docker تعمل عند نقطة أبكر على مسار الحزمة، فلا يُسأل UFW أبدًا. يوضّح هذا الدليل التجاوز، ويشرح آليته، ثم يغطي الحلّين الفعّالين: نشر المنافذ على 127.0.0.1، والتصفية داخل سلسلة DOCKER-USER. وإن كان UFW نفسه جديدًا عليك، فأعدّه أولًا بالاستعانة بـ دليل أساسيات جدار الحماية UFW، لأن جدار الحماية القائم على الرفض الافتراضي يبقى الأساس الصحيح لكل شيء آخر على الخادم.
راقب التجاوز على خادمك بنفسك
ابدأ من خادم VPS يعمل عليه UFW بسياسة رفض افتراضية للاتصالات الواردة. شغّل حاوية ويب بمنفذ منشور:
sudo ufw status verbose
docker run -d --name web -p 8080:80 nginx:1.29-alpineيعرض ufw status verbose القيمة Default: deny (incoming), allow (outgoing) ولا توجد أي قاعدة للمنفذ 8080. وبحسب تقرير جدار الحماية نفسه، فالمنفذ مغلق. اختبر الآن من جهاز آخر، لا من الخادم نفسه:
curl -I http://your-vps-ip:8080/HTTP/1.1 200 OKالحاوية تجيب. أضف قاعدة رفض صريحة واختبر مرة أخرى:
sudo ufw deny 8080/tcpيظل المنفذ يجيب، لأن قاعدة الرفض تقع في سلسلة لا تزورها الحزمة أبدًا. لم يفشل UFW. بل لم يُستشَر قط. وهذا أيضًا سبب اختباء المشكلة بهذا الإتقان: لا تُطبع أي رسالة خطأ في أي مكان، والنشر ينجح، ومخرجات حالة جدار الحماية تبدو تمامًا كخادم سليم ومُحكَم الإغلاق.
الآلية: PREROUTING يعمل قبل INPUT
تعالج النواة الحزمة الواردة بترتيب ثابت، والمشكلة كلها تكمن في ذلك الترتيب.
- تعمل
PREROUTINGأولًا. قد تعيد القواعد هنا كتابة وجهة الحزمة، وهذا بالضبط ما تفعله قاعدة Docker لمنفذ منشور. - يأتي قرار التوجيه بعد ذلك. الحزمة الموجهة إلى المضيف نفسه تذهب إلى سلسلة
INPUT. والحزمة الموجهة إلى أي جهاز آخر تذهب إلى سلسلةFORWARD. - قواعد UFW تقع في
INPUT. وقواعد Docker تقع فيFORWARD.
انظر إلى قاعدة Docker للحاوية التي بدأتها للتو:
sudo iptables -t nat -L DOCKER -nChain DOCKER (2 references)
target prot opt source destination
RETURN 0 -- 0.0.0.0/0 0.0.0.0/0
DNAT 6 -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:8080 to:172.17.0.2:80سطر DNAT هو القصة كاملة. فأي حزمة تصل للمنفذ 8080 تُعاد كتابة وجهتها إلى 172.17.0.2:80، عنوان الحاوية على الشبكة الجسرية (bridge) الخاصة بـ Docker. وبعد إعادة الكتابة لم تعد الحزمة موجهة إلى المضيف، فيرسلها قرار التوجيه على مسار FORWARD، حيث أضاف Docker مسبقًا قواعد تقبل المرور إلى شبكاته الخاصة. أما قاعدتك deny 8080/tcp فتنتظر في INPUT حزمة لن تصل أبدًا.
على Ubuntu 24.04، أمر iptables هو واجهة أمامية فوق nftables، لكن ترتيب السلاسل والنتيجة متطابقان. يكتب كل من UFW وDocker في مسار معالجة الحزم نفسه في النواة، ونقطة دخول Docker أبكر.
الحل اليومي: انشر المنافذ على 127.0.0.1
معظم الحاويات لم تكن بحاجة أصلًا إلى أن تكون عامة. قاعدة بيانات، أو خادم تطبيق خلف وكيل عكسي (reverse proxy)، أو لوحة إدارة، أو نقطة نهاية للمقاييس: لا ينبغي لأي منها أن يستجيب للإنترنت مباشرة. انشرها على عنوان الاسترجاع المحلي (loopback):
docker run -d --name web -p 127.0.0.1:8080:80 nginx:1.29-alpineأو في ملف Compose:
services:
web:
image: nginx:1.29-alpine
ports:
- "127.0.0.1:8080:80"ينجح هذا لأن قاعدة DNAT الخاصة بـ Docker تطابق الآن فقط الحزم الموجهة إلى 127.0.0.1، ولا يمكن لحزمة قادمة من الإنترنت أن تستهدف هذه الوجهة بشكل مشروع أبدًا، فتُسقطها النواة قبل تشغيل أي قاعدة في جدار الحماية. يصبح المنفذ قابلًا للوصول من المضيف فقط، ولا شيء غيره. تحقق من الربط:
sudo ss -tlnp | grep 8080تريد أن ترى 127.0.0.1:8080 في المخرجات، لا 0.0.0.0:8080 ولا [::]:8080. ثم تأكد من جهاز آخر أن curl http://your-vps-ip:8080/ تُرفَض.
أما الخدمات التي يجب أن تواجه الإنترنت، فشغّل لها وكيلًا عكسيًا واحدًا يملك المنفذين 80 و443 ويوجّه المرور بحسب اسم المضيف، ولا تنشر شيئًا آخر. هذا هو النمط الذي يبنيه دليل الوكيل العكسي Traefik، وهو ما يجعل تطبيقًا مستضافًا ذاتيًا مثل Nextcloud على VPS غير قابل للوصول إلا عبر وكيله. أما كيفية تعريف مدخلات ports: وبقية سير عمل Compose، فمشروحة في دليل أساسيات Docker Compose.
مع كل حاوية داخلية على عنوان الاسترجاع المحلي، يعود UFW إلى مهمته المعتادة: حراسة المنافذ التي يخدمها المضيف نفسه. ابنِ مجموعة القواعد هنا، ثم نفّذ الأوامر بالترتيب:
التصفية الحقيقية: سلسلة DOCKER-USER
أحيانًا يجب أن يبقى منفذ الحاوية منشورًا على الشبكة لكن مقيّدًا، كمنفذ نسخة طبق الأصل (replica) لقاعدة بيانات لا يصله سوى عنوان مكتب واحد. لهذا الغرض، يوفر Docker سلسلة DOCKER-USER. كل حزمة متجهة إلى أي حاوية تمر عبر DOCKER-USER قبل قواعد القبول الخاصة بـ Docker نفسه، ولا يكتب Docker فيها أي قواعد إطلاقًا. هذه السلسلة موجودة من أجل قواعدك أنت، ويترك Docker محتواها دون مساس عبر إعادة تشغيل الخدمة الخلفية (daemon).
ثمة فخ قبل تنفيذ الأمر: عندما تصل الحزمة إلى DOCKER-USER، تكون إعادة كتابة DNAT قد وقعت بالفعل. منفذ وجهة الحزمة هو منفذ الحاوية (80 في مثالنا)، لا المنفذ المنشور (8080). لذلك فإن قاعدة تطابق --dport 8080 لا تطابق شيئًا. الطريقة الموثوقة هي مطابقة المنفذ الذي طلبه العميل أصلًا، وهو ما يتذكره متتبِّع الاتصالات (conntrack) الخاص بالنواة:
sudo iptables -I DOCKER-USER -i eth0 -p tcp -m conntrack --ctorigdstport 8080 --ctdir ORIGINAL ! -s 10.0.0.10 -j DROPاقرأها هكذا: بالنسبة للحزم التي دخلت عبر eth0 وتنتمي إلى اتصال كان منفذ وجهته الأصلي 8080، ارفض كل ما لم يُرسَل من 10.0.0.10. مطابقة --ctdir ORIGINAL تحصر القاعدة في اتجاه العميل نحو الحاوية، فلا تُضبَط حزم الرد بالخطأ. استبدل eth0 بواجهتك العامة؛ يسمّيها لك الأمر ip route | grep default. اختبرها بالطريقة نفسها كما سبق: تنجح curl من العنوان المسموح، وتنتهي مهلة الاتصال من أي مكان آخر.
القواعد المضافة بأمر iptables تختفي عند إعادة الإقلاع. وبما أن UFW يدير هذا الجدار بالفعل، فالمكان المناسب لجعلها دائمة هو /etc/ufw/after.rules. أضف كتلة في نهاية الملف:
*filter
:DOCKER-USER - [0:0]
-A DOCKER-USER -i eth0 -p tcp -m conntrack --ctorigdstport 8080 --ctdir ORIGINAL ! -s 10.0.0.10 -j DROP
COMMITثم نفّذ sudo ufw reload. يعيد UFW تنفيذ ذلك الملف مع كل إعادة تحميل وكل إقلاع، فتصبح تصفية حاوياتك الآن في المكان نفسه مع بقية جدار حمايتك، وتنجو من إعادة الإقلاع ومن ترقية Docker على حد سواء.
لماذا لا ينبغي تعطيل تكامل iptables في Docker
تقترح إجابات أقدم لهذه المشكلة ضبط { "iptables": false } في /etc/docker/daemon.json. لا تفعل ذلك. فقواعد جدار الحماية في Docker تفعل أكثر بكثير من مجرد نشر المنافذ. فقاعدة التمويه (masquerade) هي ما يمنح الحاويات وصولًا صادرًا إلى الإنترنت عبر عنوان المضيف، ومع تعطيل التكامل، لن تستطيع الحاويات سحب الصور، ولا الوصول إلى مرايا الحزم، ولا استدعاء أي API خارجية (واجهة برمجة التطبيقات، application programming interface). وقواعد DNAT هي ما يجعل -p يعمل أصلًا، فيتوقف عمل المنافذ المنشورة تمامًا. وتختفي أيضًا قواعد العزل التي تُبقي شبكات Compose المنفصلة متفرقة. ستكون قد أصلحت التجاوز بتحطيم شبكة الحاويات، وستصبح كل واحدة من تلك القواعد عليك أنت أن تكتبها وتصونها يدويًا. توثيق Docker نفسه يصف هذا الإعداد بأنه لمن ينوي فعل ذلك بالضبط. وسلسلة DOCKER-USER موجودة خصيصًا حتى لا يحتاج أحد إلى هذا المفتاح.
جانب IPv6 من المشكلة نفسها
تحقق أولًا من شكل المنفذ المنشور على IPv6:
sudo ss -tlnp | grep 8080منذ Docker Engine 27، يدير Docker ip6tables افتراضيًا. وعلى شبكة Docker مفعّل فيها IPv6، يحصل المنفذ المنشور على معاملة DNAT نفسها في جداول IPv6، فالتجاوز نفسه موجود هناك أيضًا وينطبق الحل نفسه: سلسلة DOCKER-USER موجودة أيضًا في ip6tables، فانسخ قاعدتك بالأمر sudo ip6tables -I DOCKER-USER ... واختبر من الخارج بـ curl مقابل عنوان IPv6 العام لخادمك، على سبيل المثال curl -6 http://[2001:db8:2a::1]:8080/.
أما على شبكة بلا IPv6، فيُعالَج عملاء IPv6 بدلًا من ذلك عبر docker-proxy، وهي عملية عادية في مساحة المستخدم (user-space) تستمع على [::]:8080 وتُحوِّل المرور إلى الحاوية عبر IPv4. والمرور المتجه إلى عملية على المضيف يمر فعلًا عبر INPUT، فيستطيع UFW تصفية ذلك المسار، لكن فقط إن كان UFW يدير IPv6 أصلًا. أما هل يديره فعلًا، والطرق الأخرى التي تنفتح منها ثغرة IPv6 على خادم VPS، فموضوع دليل UFW وIPv6.
النشر على عنوان الاسترجاع المحلي يتجاوز المسألة كلها: -p 127.0.0.1:8080:80 يربط عنوان الاسترجاع IPv4 فقط، فلا يوجد أي مستمع IPv6، ولا شيء يمكن الوصول إليه من الخارج على أي من المكدّسين.
النمط الذي يصمد
- انشر كل منفذ داخلي على
127.0.0.1، حتى لا يُكشَف أصلًا. - أوكِل الجانب العام إلى وكيل عكسي واحد يملك المنفذين 80 و443.
- أبقِ UFW على الرفض الافتراضي للمضيف، مع السماح بـ SSH ومنافذ الوكيل.
- صفِّ منافذ الحاويات العامة فعلًا داخل
DOCKER-USER، بمطابقة منفذ الوجهة الأصلي، وثبِّتها في/etc/ufw/after.rules. - أبقِ تكامل iptables في Docker مفعّلًا.
بمجرد إعداد هذا مرة واحدة، تزول المفاجأة: يصف ufw status المضيف، وتصف DOCKER-USER الحاويات. لا شيء يُنشَر بالصدفة، وأي أمر docker run -p تكتبه بعد ذلك يكشف بالضبط ما قصدته.
FAQ
لماذا أستطيع الوصول إلى حاوية Docker رغم أن UFW يحظر المنفذ؟
لأن Docker ينشر المنفذ بقاعدة DNAT في سلسلة PREROUTING، التي تعيد كتابة وجهة الحزمة إلى عنوان الحاوية قبل حدوث أي تصفية. ثم تسلك الحزمة مسار FORWARD، بينما تقع قواعد UFW في INPUT، وهي سلسلة لا تدخلها الحزمة أبدًا. لا يُستشار جدار الحماية أبدًا، فلا تؤثر قواعد الرفض فيه على منافذ الحاويات المنشورة.
كيف أجعل UFW يحظر منافذ Docker المنشورة؟
UFW نفسه لا يستطيع، لأن قواعده في السلسلة الخاطئة. إما أن تتوقف عن كشف المنفذ، بنشره كـ 127.0.0.1:8080:80 بحيث لا يصله سوى المضيف، أو أن تصفّي في سلسلة DOCKER-USER بقاعدة iptables تطابق منفذ الوجهة الأصلي عبر conntrack. اجعل تلك القاعدة دائمة في /etc/ufw/after.rules لتنجو من إعادة الإقلاع ومن ufw reload.
هل ينبغي أن أضبط "iptables": false في daemon.json الخاص بـ Docker؟
لا. هذا الإعداد يزيل كل قواعد جدار الحماية وNAT الخاصة بـ Docker، وهذا يكسر أكثر بكثير من مجرد التجاوز. تفقد الحاويات وصولها الصادر إلى الإنترنت لأن قاعدة التمويه تختفي، وتتوقف المنافذ المنشورة عن العمل لأن قواعد DNAT تختفي. استخدم بدلًا من ذلك النشر على عنوان الاسترجاع المحلي وسلسلة DOCKER-USER؛ فهما يعالجان الكشف دون كسر شبكة الحاويات.
هل يتجاوز Docker جدار UFW على IPv6 أيضًا؟
في Docker Engine 27 وما بعده، تُدار ip6tables افتراضيًا، فأي منفذ منشور على شبكة Docker مفعّل فيها IPv6 تُعاد كتابته متجاوزًا UFW تمامًا كما في IPv4، ويحتاج إلى نسخ القاعدة نفسها في DOCKER-USER باستخدام ip6tables. أما على الشبكات بلا IPv6، فتستمع عملية docker-proxy على [::]، وهذا المرور يمر فعلًا عبر INPUT، حيث يستطيع UFW تصفيته إن كان يدير IPv6. النشر على 127.0.0.1 يتجنب الحالتين معًا، لأنه لا شيء يستمع على IPv6 أصلًا.