چند سرویس روی یک پورت ۴۴۳: مسیریابی SNI با Nginx Stream
پیکربندی ماژول stream در Nginx با ssl_preread برای هدایت ترافیک TLS چند دامنه به سرویسهای جدا روی یک IP و پورت ۴۴۳، حفظ IP واقعی کاربر با Proxy Protocol، و خطاهای رایج مثل default_server ضمنی.
روی سرورهایی که نگهداری میکنم، چند سایت و سرویس مستقل کنار هم اجرا میشوند: سایت شخصی، API یک سامانه حسابداری، پنل یک ابزار اینستاگرام و چند سرویس دیگر. همه باید روی پورت استاندارد ۴۴۳ در دسترس باشند و بعضیشان TLS را خودشان مدیریت میکنند. راه معمول (یک Nginx که TLS همه را باز میکند و پشت سرش proxy میکند) برای همه اینها مناسب نیست. مسیریابی بر اساس SNI در لایه stream این مسئله را تمیز حل میکند. این مقاله پیکربندی واقعی، دلیل هر خط و خطاهایی است که در عمل دیدهام.
SNI چیست و چرا در لایه ۴
وقتی مرورگر به یک سایت HTTPS وصل میشود، در اولین پیام (ClientHello) نام دامنهای که میخواهد را بهصورت رمزنشده میفرستد. این همان SNI است و دلیلش این است که سرور بداند کدام گواهی را نشان دهد. ماژول stream در Nginx با ssl_preread میتواند همین نام را بخواند، بدون اینکه TLS را باز کند، و کل اتصال TCP را بر اساس آن به مقصد دیگری بفرستد.
نتیجه این است که هر سرویس پشت این مسیریاب، TLS خودش را با گواهی خودش مدیریت میکند و مسیریاب فقط ترافیک رمزشده را جابهجا میکند. میشود یک سرویس را بدون دست زدن به بقیه عوض، ریاستارت یا جابهجا کرد.
نکات کلیدی
- چند سرویس مستقل روی یک IP و پورت ۴۴۳.
- مسیریاب TLS را باز نمیکند و به گواهیها دسترسی ندارد.
- هر سرویس گواهی و تنظیمات TLS خودش را دارد.
پیکربندی پایه
بلوک stream در nginx.conf همسطح http قرار میگیرد، نه داخل آن. با map، نام SNI به یک مقصد ترجمه میشود و برای هر نام ناشناخته یک مقصد پیشفرض تعریف میکنیم. خود Nginx هم برای سایتهایی که TLS را در لایه http باز میکنند، روی پورت لوکال دیگری گوش میدهد.
stream {
map $ssl_preread_server_name $sni_upstream {
app.example.com 127.0.0.1:8443; # سرویسی که TLS خودش را دارد
api.example.com 127.0.0.1:9443;
default 127.0.0.1:8444; # بقیه دامنهها → nginx http
}
server {
listen 443;
listen [::]:443;
ssl_preread on;
proxy_pass $sni_upstream;
proxy_protocol on; # IP واقعی کاربر را به مقصد بفرست
proxy_connect_timeout 5s;
}
}
http {
server {
listen 127.0.0.1:8444 ssl proxy_protocol;
server_name example.com www.example.com;
set_real_ip_from 127.0.0.1;
real_ip_header proxy_protocol;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
# ...
}
}حفظ IP واقعی کاربر با Proxy Protocol
وقتی مسیریاب اتصال را به سرویس دیگری روی همان سرور میدهد، سرویس مقصد IP کاربر را نمیبیند؛ همه اتصالها از 127.0.0.1 میآیند. نتیجه این است که لاگها، محدودیت نرخ درخواست و آنالیتیکس همه بیمعنی میشوند.
Proxy Protocol یک سرآیند کوچک در ابتدای اتصال اضافه میکند که IP و پورت واقعی کاربر را دارد. نکته مهم: هر دو طرف باید آن را بدانند. اگر مسیریاب proxy_protocol on دارد ولی مقصد listen … proxy_protocol ندارد، اتصال خراب میشود؛ و برعکس. مقصد هم باید فقط از آدرس مسیریاب به این سرآیند اعتماد کند (set_real_ip_from)، وگرنه هر کسی میتواند IP جعلی بفرستد.
خطای رایج: default_server ضمنی
اگر چند بلوک server روی یک پورت گوش بدهند و هیچکدام صریحاً default_server نباشند، Nginx اولین بلوکی را که خوانده، پیشفرض میکند. یعنی درخواستی با نام دامنهای که تعریف نشده (یا با DNS قدیمی رسیده) به سایتی میرود که اتفاقاً اول خوانده شده؛ و ترتیب خواندن به نام فایلها بستگی دارد. من یک بار دقیقاً همین را تجربه کردم: بازدیدکنندههایی که DNS قدیمی داشتند به سامانه دیگری ریدایرکت میشدند و مرورگر آن ریدایرکت ۳۰۱ را به خاطر میسپرد. ماجرای کاملش در وقتی DNS قدیمی کاربر را به سایت اشتباه فرستاد است.
راهحل: برای هر پورت یک بلوک default_server صریح بگذارید که برای نامهای ناشناخته فقط خطا یا صفحه خنثی برگرداند. در لایه stream هم مقدار default در map را آگاهانه انتخاب کنید.
آزمایش و عیبیابی
قبل از هر reload، nginx -t را اجرا کنید؛ خطای نحوی در بلوک stream کل Nginx را از کار میاندازد. برای آزمایش مسیریابی، با openssl s_client و پارامتر servername میشود دید هر نام به کدام سرویس و کدام گواهی میرسد، بدون اینکه DNS را عوض کنید.
برای عیبیابی در لایه stream، یک log_format جدا که نام SNI و مقصد انتخابشده را ثبت کند، خیلی کمک میکند؛ لاگهای http این اطلاعات را ندارند.
# کدام گواهی برای این نام برگردانده میشود؟
openssl s_client -connect 203.0.113.10:443 -servername app.example.com </dev/null 2>/dev/null \
| openssl x509 -noout -subject -dates
# درستی پیکربندی قبل از reload
nginx -t && systemctl reload nginxپشت CDN
اگر سایت پشت CDN است، اتصالها از IP لبههای CDN میآیند و نام SNI همان دامنه شماست. مسیریابی SNI همچنان کار میکند، اما IP واقعی کاربر را CDN معمولاً در سرآیند HTTP (مثل X-Forwarded-For یا X-Real-IP) میفرستد، نه در Proxy Protocol. در این حالت سرویس مقصد باید به آن سرآیند فقط از IPهای CDN اعتماد کند. دو مسئله دیگر هم هست: گواهی سرور مبدأ باید معتبر بماند حتی اگر کاربران گواهی CDN را میبینند (تجربهام از این را در گواهی SSL منقضی پشت CDN نوشتهام)، و بررسی کنید CDN با SNI درست به مبدأ وصل میشود.
جمعبندی
مسیریابی SNI با Nginx stream و ssl_preread اجازه میدهد چند سرویس مستقل، هر کدام با TLS و گواهی خودشان، روی یک IP و پورت ۴۴۳ کار کنند. برای اینکه قابل اعتماد بماند: Proxy Protocol را در هر دو طرف هماهنگ کنید، default_server صریح بگذارید، با openssl s_client آزمایش کنید و قبل از هر reload از nginx -t استفاده کنید. این معماری روی سرورهای زیرساخت پروژههایم در حال کار است؛ اگر برای سرور کسبوکارتان چنین تنظیماتی لازم دارید، سرور و زیرساخت را ببینید.