رفع مشکل عبور Docker از فایروال UFW
چرا پورتهای Docker با وجود deny در UFW باز میمانند؟ علت ایجاد قوانین iptables توسط Docker و 2 راه حل عملی برای امنیت کامل شبکه را اینجا بخوانید.
چرا Docker از UFW عبور میکند
Docker از UFW عبور میکند زیرا پورتهای منتشر شده در کانتینر، هرگز از قوانین فایروال مدیریت شده توسط UFW عبور نمیکنند. وقتی شما docker run -p 8080:80 را اجرا میکنید، Docker یک قانون DNAT (ترجمه آدرس شبکه مقصد) را در زنجیره PREROUTING از جدول nat هسته (kernel) مینویسد. آن قانون، قبل از اینکه هسته مقصد بسته را تعیین کند، مقصد هر بسته را به آدرس خصوصی کانتینر تغییر میدهد. سپس بسته تغییریافته از طریق زنجیره FORWARD که تحت کنترل Docker است، به داخل کانتینر هدایت میشود. قوانین UFW در زنجیره INPUT قرار دارند و بسته هرگز وارد آن نمیشود. بنابراین ufw status حالت deny پیشفرض را نشان میدهد، sudo ufw deny 8080 موفقیت را گزارش میکند، اما پورت 8080 همچنان به کل اینترنت پاسخ میدهد.
این یک باگ در Docker نیست و UFW نیز خراب نیست. هر دو ابزار، یک فایروال هسته را برنامهریزی میکنند. قوانین Docker صرفاً در نقطه زودتری از مسیر بسته عمل میکنند، بنابراین از UFW هیچ سوالی پرسیده نمیشود. این راهنما این عبور را نشان میدهد، مکانیسم آن را توضیح میدهد و سپس دو راه حل موثر را بررسی میکند: انتشار پورتها در 127.0.0.1 و فیلتر کردن در زنجیره DOCKER-USER. اگر با خود UFW آشنایی ندارید، ابتدا آن را با راهنمای اصول اولیه فایروال UFW تنظیم کنید، زیرا یک فایروال با حالت deny پیشفرض، همچنان پایه مناسبی برای سایر موارد در سرور است.
Bypass را در سرور خود مشاهده کنید
از یک VPS شروع کنید که در آن UFW فعال است و سیاست پیشفرض آن deny برای ترافیک ورودی است. یک container وب با یک port منتشر شده اجرا کنید:
sudo ufw status verbose
docker run -d --name web -p 8080:80 nginx:1.29-alpineدستور ufw status verbose خروجی Default: deny (incoming), allow (outgoing) را نشان میدهد و هیچ rule برای port 8080 وجود ندارد. طبق گزارش خودِ فایروال، این port بسته است. اکنون از یک دستگاه دیگر، و نه از خودِ سرور، تست کنید:
curl -I http://your-vps-ip:8080/HTTP/1.1 200 OKcontainer پاسخ میدهد. یک rule صریح deny اضافه کنید و دوباره تست کنید:
sudo ufw deny 8080/tcpport همچنان پاسخ میدهد، زیرا rule deny در chain ای قرار دارد که packet هرگز از آن عبور نمیکند. UFW شکست نخورده است؛ بلکه اصلاً از آن استفاده نشده است. به همین دلیل است که این مشکل بسیار پنهان میماند: هیچ خطایی در هیچکجا چاپ نمیشود، deployment با موفقیت انجام میشود و خروجی status فایروال دقیقاً شبیه به یک سرور سالم و محدود شده به نظر میرسد.
مکانیزم: PREROUTING قبل از INPUT اجرا میشود
هسته (kernel) بستههای ورودی را با یک ترتیب مشخص پردازش میکند و تمام مشکل از همین ترتیب ناشی میشود.
- ابتدا
PREROUTINGاجرا میشود. قوانین در این مرحله ممکن است مقصد بسته را تغییر دهند؛ قانون Docker برای یک پورت منتشر شده (published port) دقیقاً همین کار را انجام میدهد. - مرحله بعد، تصمیمگیری برای مسیریابی (routing decision) است. بستهای که مقصد آن خودِ host است، به زنجیره
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. پس از این تغییر، بسته دیگر مقصدش host نیست، بنابراین تصمیم مسیریابی آن را به مسیر FORWARD میفرستد؛ جایی که Docker قبلاً قوانینی برای پذیرش ترافیک در شبکههای خود اضافه کرده است. قانون deny 8080/tcp شما در INPUT منتظر بستهای میماند که هرگز نمیرسد.
در Ubuntu 24.04، دستور iptables یک رابط کاربری (front end) روی nftables است، اما ترتیب زنجیرهها و نتیجه نهایی یکسان است. هر دو سیستم UFW و Docker در یک خط لوله (pipeline) بستههای هسته مینویسند و نقطه ورود Docker زودتر است.
راهکار همیشگی: انتشار پورتها روی 127.0.0.1
بسیاری از کانتینرها در اصل نیازی به عمومی بودن ندارند. یک پایگاه داده، یک سرور اپلیکیشن پشت یک reverse proxy، یک پنل مدیریت، یا یک نقطه اتصال (endpoint) برای متریکها: هیچکدام از اینها نباید مستقیماً به اینترنت پاسخ دهند. آنها را روی آدرس 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 است مطابقت دارد؛ و یک بسته از سمت اینترنت هرگز نمیتواند به طور قانونی این مقصد را داشته باشد، بنابراین هسته (kernel) آن را قبل از اجرای هر قانون فایروال حذف میکند. پورت از طریق host قابل دسترسی است و از هیچ جای دیگری. اتصال (binding) را بررسی کنید:
sudo ss -tlnp | grep 8080شما باید 127.0.0.1:8080 را در خروجی ببینید، نه 0.0.0.0:8080 یا [::]:8080. سپس از یک دستگاه دیگر تایید کنید که curl http://your-vps-ip:8080/ رد (refused) میشود.
برای سرویسهایی که باید در معرض اینترنت باشند، یک reverse proxy اجرا کنید که مالک پورتهای 80 و 443 باشد و بر اساس hostname مسیریابی کند، و هیچ چیز دیگری را منتشر نکنید. این همان الگویی است که راهنمای reverse proxy در Traefik بر پایه آن بنا شده است، و روشی است که اپلیکیشنهای self-hosted مانند Nextcloud روی یک VPS را فقط از طریق پروکسی قابل دسترسی نگه میدارد. نحوه تعریف ورودیهای ports: و سایر مراحل گردش کار Compose در راهنمای اصول اولیه Docker Compose پوشش داده شده است.
با قرار دادن هر کانتینر داخلی روی loopback، UFW به وظیفه عادی خود بازمیگردد: محافظت از پورتهایی که خود host سرویسدهی میکند. مجموعه قوانین را در اینجا بسازید، سپس دستورات را به ترتیب اجرا کنید:
فیلترینگ واقعی: زنجیره DOCKER-USER
گاهی اوقات یک پورت کانتینر باید در شبکه منتشر (published) باقی بماند اما محدود شود؛ برای مثال، پورت یک replica دیتابیس که فقط یک آدرس اداری خاص مجاز به دسترسی به آن باشد. برای این منظور، Docker زنجیره DOCKER-USER را ارائه میدهد. هر بستهای که به سمت هر کانتینری میرود، پیش از رسیدن به قوانین accept خودِ Docker، از DOCKER-USER عبور میکند و Docker هرگز قانونی در آن زنجیره نمینویسد. این زنجیره برای استفاده شماست و Docker محتویات آن را در هنگام ریاستارت شدن daemon تغییر نمیدهد.
یک نکته مهم پیش از دستور: زمانی که یک بسته به DOCKER-USER میرسد، عملیات DNAT rewrite قبلاً انجام شده است. پورت مقصد بسته، پورت کانتینر (در مثال ما 80) است، نه پورت منتشر شده (8080). بنابراین، قانونی که با --dport 8080 مطابقت داشته باشد، هیچ چیزی را پیدا نمیکند. روش مطمئن، مطابقت با پورتی است که کلاینت در ابتدا فراخوانی کرده است و connection tracker هسته (kernel) آن را به خاطر دارد:
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 ارسال نشده را drop کن. تطبیق --ctdir ORIGINAL این قانون را فقط به جهت کلاینت-به-کانتینر محدود میکند، بنابراین بستههای پاسخ به اشتباه مسدود نمیشوند. عبارت eth0 را با اینترفیس عمومی خود جایگزین کنید؛ ip route | grep default نام آن است. آن را مانند قبل تست کنید: curl از آدرس مجاز با موفقیت انجام میشود و از هر جای دیگر، اتصال با timeout مواجه میشود.
قوانینی که با دستور 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 آن فایل را در هر بار reload و هر بار boot مجدداً اجرا میکند، بنابراین فیلترینگ کانتینر شما اکنون در همان جایی قرار دارد که بقیه فایروال شما قرار دارد و هم در ریبوت و هم در ارتقای Docker باقی میماند.
چرا نباید قابلیت iptables integration در Docker را غیرفعال کنید
پاسخهای قدیمیتر به این مسئله، تنظیم کردن { "iptables": false } در /etc/docker/daemon.json را پیشنهاد میدهند. این کار را انجام ندهید. قوانین فایروال Docker فراتر از انتشار پورتها (publish ports) عمل میکنند. قانون masquerade مسئول ایجاد دسترسی اینترنت خروجی برای کانتینرها از طریق آدرس میزبان (host) است؛ بنابراین با غیرفعال کردن این قابلیت، کانتینرها نمیتوانند imageها را pull کنند، به mirrorهای پکیج دسترسی داشته باشند، یا با هیچ API خارجی ارتباط برقرار کنند. قوانین DNAT عامل اصلی کارکرد -p هستند، بنابراین در صورت غیرفعالسازی، پورتهای منتشر شده کاملاً از کار میافتند. قوانین isolation که شبکههای مجزای Compose را از هم جدا نگه میدارند نیز از بین میروند. شما با از کار انداختن شبکه کانتینر، سعی در رفع مشکل bypass دارید؛ در این حالت، شما مجبور خواهید بود تمام آن قوانین را به صورت دستی بنویسید و نگهداری کنید. مستندات خود Docker این تنظیم را مخصوص افرادی میداند که دقیقاً قصد انجام همین کار را دارند. زنجیره DOCKER-USER دقیقاً برای این وجود دارد که هیچکس به این سوئیچ نیاز نداشته باشد.
بخش IPv6 از همان مشکل
ابتدا وضعیت پورت منتشر شده را در IPv6 بررسی کنید:
sudo ss -tlnp | grep 8080از نسخه Docker Engine 27، Docker به صورت پیشفرض ip6tables را مدیریت میکند. در یک شبکه Docker که IPv6 در آن فعال است، یک پورت منتشر شده در جداول IPv6 نیز همان DNAT را دریافت میکند؛ بنابراین همان مشکل Bypass در آنجا نیز وجود دارد و همان راه حل اعمال میشود: زنجیره DOCKER-USER در ip6tables نیز وجود دارد، پس قانون خود را با استفاده از sudo ip6tables -I DOCKER-USER ... بازتاب (mirror) دهید و با استفاده از curl از خارج از شبکه، به آدرس IPv6 عمومی سرور خود، مثلاً curl -6 http://[2001:db8:2a::1]:8080/، تست کنید.
در شبکهای بدون IPv6، کلاینتهای IPv6 توسط docker-proxy مدیریت میشوند؛ یک فرآیند معمولی در فضای کاربر (user-space) که روی [::]:8080 گوش میدهد و ترافیک را از طریق IPv4 به داخل کانتینر ارسال میکند. ترافیک به یک فرآیند میزبان (host process) از طریق INPUT عبور میکند، بنابراین UFW میتواند آن مسیر را فیلتر کند، اما تنها در صورتی که UFW در حال مدیریت IPv6 باشد. اینکه آیا UFW مدیریت میکند یا خیر، و سایر روشهایی که باعث ایجاد شکاف IPv6 در یک VPS میشوند، موضوع راهنمای UFW و IPv6 است.
انتشار روی loopback این پرسش را بیاهمیت میکند: -p 127.0.0.1:8080:80 فقط IPv4 loopback را bind میکند، بنابراین هیچ listener در IPv6 وجود ندارد و هیچ چیزی از خارج از شبکه در هیچکدام از پشتهها (stacks) قابل دسترسی نیست.
الگوی نگهدارنده
- تمام پورتهای داخلی را در
127.0.0.1منتشر کنید تا از همان ابتدا در معرض دسترسی نباشند. - پورتهای سمت عمومی را به یک reverse proxy اختصاص دهید که مالک پورتهای 80 و 443 است.
- تنظیمات پیشفرض UFW را روی deny قرار دهید و فقط پورتهای SSH و proxy را مجاز کنید.
- پورتهای عمومی و واقعی کانتینر را در
DOCKER-USERفیلتر کنید؛ این فیلتر بر اساس پورت مقصد اصلی انجام میشود که در/etc/ufw/after.rulesذخیره شده است. - قابلیت integration مربوط به iptables در Docker را روشن نگه دارید.
با یک بار تنظیم این کار، از بروز اتفاقات غیرمنتظره جلوگیری میکنید: ufw status وضعیت host را توصیف میکند و DOCKER-USER وضعیت containerها را. هیچ چیزی به صورت تصادفی منتشر نمیشود و در docker run -p بعدی که تایپ میکنید، دقیقاً همان چیزی که قصد داشتید، در معرض دسترسی قرار میگیرد.
FAQ
چرا با وجود مسدود بودن پورت توسط UFW، همچنان میتوانم به کانتینر Docker دسترسی داشته باشم؟
دلیل این اتفاق این است که Docker پورت را با یک rule از نوع DNAT در chain مربوط به PREROUTING منتشر میکند؛ این کار باعث میشود قبل از انجام هرگونه فیلترینگ، مقصد بسته به آدرس کانتینر تغییر یابد. بسته سپس مسیر FORWARD را طی میکند، در حالی که قوانین UFW در chain مربوط به INPUT قرار دارند و بسته هرگز وارد آن نمیشود. از آنجایی که فایروال اصلاً بررسی نمیشود، قوانین deny آن تأثیری روی پورتهای منتشر شده کانتینر ندارند.
چگونه میتوانم UFW را مجبور کنم پورتهای منتشر شده Docker را مسدود کند؟
خودِ UFW نمیتواند این کار را انجام دهد، زیرا قوانین آن در chain اشتباهی قرار دارند. یا با منتشر کردن پورت به صورت 127.0.0.1:8080:80 (به طوری که فقط host به آن دسترسی داشته باشد) از باز بودن آن جلوگیری کنید، یا در chain مربوط به DOCKER-USER با استفاده از یک iptables rule که از طریق conntrack با پورت مقصد اصلی مطابقت دارد، فیلترینگ را انجام دهید. آن rule را در /etc/ufw/after.rules ذخیره کنید تا پس از reboot و ufw reload باقی بماند.
آیا باید مقدار "iptables": false را در فایل daemon.json در Docker تنظیم کنم؟
خیر. این تنظیم تمام قوانین firewall و NAT مربوط به Docker را حذف میکند که پیامدهای بسیار بیشتری نسبت به دور زدن فایروال دارد. کانتینرها دسترسی outbound به اینترنت را از دست میدهند چون rule مربوط به masquerade حذف میشود، و پورتهای منتشر شده نیز از کار میافتند چون ruleهای DNAT حذف شدهاند. در عوض، از loopback publishing و chain مربوط به DOCKER-USER استفاده کنید؛ این روش بدون مختل کردن شبکه کانتینر، مشکل exposure را حل میکند.
آیا Docker در IPv6 نیز UFW را دور میزند؟
در Docker Engine نسخه 27 و بالاتر، مدیریت ip6tables به صورت پیشفرض فعال است؛ بنابراین پورت منتشر شده در یک شبکه Docker با قابلیت IPv6، دقیقاً مانند IPv4 دورِ UFW بازنویسی میشود و نیاز به mirrored کردن همان rule مربوط به DOCKER-USER با استفاده از ip6tables دارد. در شبکههای بدون IPv6، فرآیند docker-proxy روی [::] گوش میدهد و آن ترافیک از INPUT عبور میکند، جایی که اگر UFW مدیریت IPv6 را بر عهده داشته باشد، میتواند آن را فیلتر کند. استفاده از قابلیت publishing روی 127.0.0.1 از هر دو حالت جلوگیری میکند، زیرا در آن صورت هیچ چیزی روی IPv6 گوش نمیدهد.