CDN که درخواست یک سایت را به سرویس دیگری تحویل داد: SNI کافی نبود
چند دامنه پشت CDN روی یک سرور بودند و گاهی پاسخ یک سایت از سرویس دیگر میآمد. ماجرای SNI، اتصال مشترک و مسیریابی بر اساس هدر Host.
چند دامنه روی یک سرور بودند و همه پشت یک CDN قرار داشتند. ترافیک را بر اساس SNI به سرویسهای مختلف میفرستادیم. بیشتر وقتها همهچیز درست کار میکرد، اما گاهی درخواست یک سایت را سرویس سایت دیگری جواب میداد. این نوشته روایت همان خطاست: علامتها، علت و راهی که برای رفعش رفتیم. نکته اصلی این است که SNI فقط هنگام باز شدن اتصال TLS خوانده میشود، و CDN میتواند چند درخواست مربوط به دامنههای مختلف را روی همان اتصال بفرستد.
ماجرا و علامتها
چند دامنه روی یک سرور مشترک بودند و جلوی همهشان یک CDN قرار داشت. سرور مبدأ برای هر دامنه یک سرویس جدا داشت. برای اینکه همه روی یک آدرس و یک پورت جواب بدهند، از مسیریابی لایه ۴ بر اساس SNI استفاده میکردیم؛ همان الگویی که در مسیریابی SNI با Nginx Stream توضیح دادهایم. روی کاغذ تفکیک کامل بود: هر نام دامنه به سرویس خودش میرفت.
علامت خرابی این بود که گاهی درخواست یک سایت را سرویس دیگری جواب میداد. این اتفاق همیشگی نبود و همین بدتر بود. یک سایت در بیشتر موارد درست باز میشد و فقط گاهی پاسخش از سرویس دیگری میآمد. چنین خطایی با «کانفیگ غلط» جور درنمیآید، چون کانفیگ غلط معمولاً همیشه غلط است.
نکات کلیدی
- چند دامنه پشت یک CDN و روی یک سرور، با تفکیک ترافیک بر اساس SNI
- خطا دائمی نبود؛ گاهی پاسخ یک سایت از سرویس سایت دیگری میآمد
- قاعدههای مسیریابی SNI بهتنهایی درست به نظر میرسیدند
ردگیری علت
نکتهای که ردگیری را هدایت کرد خود الگوی خطا بود: وقتی قاعدهها برای هر نام دامنه درستاند اما نتیجه گاهی غلط است، یعنی چیزی غیر از «نام دامنه» در لحظه تصمیمگیری دخیل است. پس بهجای خواندن مجدد کانفیگ، پرسیدیم تصمیم مسیریابی دقیقاً در چه لحظهای گرفته میشود و از چه اطلاعاتی.
پاسخ این بود که ترمیناتور لایه ۴ فقط یک بار تصمیم میگیرد: هنگام شروع اتصال و بر اساس SNI در ClientHello. بعد از آن بایتها را بدون خواندن محتوا روی همان اتصال به سرویس انتخابشده میرساند. از اینجا به رفتار CDN رسیدیم: CDN برای هر IP مبدأ یک اتصال مشترک نگه میداشت و درخواستهای دامنههای مختلف را روی همان اتصال TLS میفرستاد.
توجه: این بخش خلاصهی منطق ردگیری است، نه گزارش دقیق گامبهگام ابزارهایی که استفاده کردیم.
نکات کلیدی
- خطای گاهبهگاه یعنی یک متغیر پنهان در تصمیم مسیریابی دخیل است
- مسیریاب لایه ۴ فقط یک بار و هنگام باز شدن اتصال تصمیم میگیرد
علت اصلی: SNI در لایه اتصال است، Host در لایه درخواست
طبق RFC 6066، کلاینت میتواند افزونه `server_name` را در ClientHello بفرستد تا سروری که چند «سرور مجازی» را پشت یک آدرس میزبانی میکند بتواند، برای نمونه، گواهی مناسب را انتخاب کند. یعنی SNI بخشی از دستدهی TLS است و یک بار برای هر اتصال ارسال میشود، نه برای هر درخواست.
HTTP لایهای بالاتر است. در یک اتصال TLS که باز مانده میتوان چند درخواست پشتسرهم (یا در HTTP/2 همزمان) فرستاد و هر کدام هدر `Host` خودش را دارد. در دنیای بدون پروکسی، مرورگر معمولاً اتصال را برای همان نامی که SNI برایش فرستاده نگه میدارد. اما CDN یک واسطه با connection pooling است: اتصال به مبدأ را بهجای هر کاربر یا هر دامنه، برای هر IP مبدأ نگه میدارد و بین دامنهها به اشتراک میگذارد.
نتیجه: اتصالی که با SNI دامنه A باز شده بود، بعداً درخواست دامنه B را هم حمل میکرد. مسیریاب لایه ۴ هیچوقت دوباره تصمیم نمیگرفت، چون از دید او همچنان «همان اتصال A» بود. پس درخواست B به سرویس A میرسید.
این مشکل تازه نیست. MDN درباره کد وضعیت 421 Misdirected Request توضیح میدهد که این کد وقتی برمیگردد که درخواست به سروری رسیده که برای ترکیب scheme و authority آن درخواست پیکربندی نشده است؛ و مثال رایج آن، استفاده مجدد از یک اتصال برای چند دامنه است. سرور اپلیکیشنی که خودش Host را بررسی میکند میتواند چنین خطایی را صریح اعلام کند. در معماری ما هیچ لایهای Host را بررسی نمیکرد، پس خطا بیصدا به پاسخ سایت دیگر تبدیل میشد.
نکات کلیدی
- SNI یک بار در ClientHello میآید؛ Host در هر درخواست HTTP تکرار میشود
- CDN با connection pooling درخواست دامنههای مختلف را روی یک اتصال میفرستد
- مسیریابی لایه ۴ هیچوقت درخواستهای بعدی روی همان اتصال را نمیبیند
راهحل: یک ترمیناتور TLS مشترک و انتخاب با Host
ترافیک ورودی از CDN را از مسیر مسیریابی SNI خارج کردیم و به یک ترمیناتور TLS مشترک فرستادیم که همه vhostها روی آن گوش میدهند. در این حالت TLS روی همان ترمیناتور پایان میگیرد، Nginx هر درخواست HTTP را میخواند و سرویس مقصد را بر اساس هدر `Host` انتخاب میکند. طبق مستندات Nginx، انتخاب سرور مجازی بر اساس `server_name` و هدر Host انجام میشود و اگر Host با هیچ نامی نخواند، درخواست به سرور پیشفرض همان listen میرسد؛ پس باید آن سرور پیشفرض را عمداً بسته نگه داشت.
نمونهی عمومی و ساده (دامنهها، آدرسها و پورت ساختگی هستند و مسیر گواهیها فقط برای مثال است):
در این الگو چون تصمیم در لایه ۷ و برای هر درخواست گرفته میشود، اشتراک اتصال توسط CDN دیگر مهم نیست. هر درخواست با Host خودش به سرویس خودش میرود. اگر هم اتصال برای دامنهای نامعتبر باز شده باشد، سرور پیشفرض آن را رد میکند.
# ترمیناتور مشترک؛ همه vhostها اینجا گوش میدهند (مقادیر نمونه)
server {
listen 8443 ssl default_server;
server_name _;
ssl_certificate /etc/ssl/example/default.pem;
ssl_certificate_key /etc/ssl/example/default.key;
return 421; # Host ناشناخته: پاسخ صریح، نه سرویس اشتباه
}
server {
listen 8443 ssl;
server_name site-a.example.com;
ssl_certificate /etc/ssl/example/site-a.pem;
ssl_certificate_key /etc/ssl/example/site-a.key;
location / {
proxy_set_header Host $host;
proxy_pass http://203.0.113.10:3001;
}
}
server {
listen 8443 ssl;
server_name site-b.example.com;
ssl_certificate /etc/ssl/example/site-b.pem;
ssl_certificate_key /etc/ssl/example/site-b.key;
location / {
proxy_set_header Host $host;
proxy_pass http://203.0.113.10:3002;
}
}نکات کلیدی
- ترافیک CDN به یک ترمیناتور TLS مشترک میرود، نه به مسیریاب لایه ۴
- انتخاب سرویس مقصد با `server_name` و هدر Host و برای هر درخواست انجام میشود
- سرور پیشفرض عمداً درخواستهای با Host ناشناخته را رد میکند
درسها و چکلیست
مسیریابی SNI در لایه ۴ برای کلاینتی که مستقیم وصل میشود خوب کار میکند، اما فرض پنهانی دارد: «هر اتصال فقط یک دامنه را حمل میکند». هر واسطهای که connection pooling یا coalescing دارد این فرض را میشکند. پشت چنین پروکسیهایی مسیریابی باید در لایه ۷ و بر اساس Host باشد.
چند نکته عملی برای جلوگیری از تکرار:
۱. قبل از اینکه ترافیک CDN را به مسیریاب SNI بدهید، مشخص کنید CDN اتصال به مبدأ را بین دامنهها به اشتراک میگذارد یا نه.
۲. سرور پیشفرض را بیصدا به یک سرویس واقعی وصل نکنید؛ ۴۲۱ یا بستن اتصال بهتر از پاسخ اشتباه است.
۳. آزمون پذیرش بنویسید که چند دامنه را پشتسرهم و موازی از مسیر CDN صدا بزند و محتوای هر پاسخ را با دامنهاش مقایسه کند. خطایی که گاهی رخ میدهد با یک درخواست تکی دیده نمیشود.
۴. ترمیناتور مشترک یعنی گواهی همه دامنهها در یک نقطه است. پایش انقضا را فراموش نکنید؛ ماجرای گواهی منقضی سرور مبدأ پشت CDN نشان میدهد چنین چیزی چقدر راحت از چشم میافتد.
اگر چند سرویس را روی یک سرور اجرا میکنید و میخواهید طراحی مسیریابی و گواهیها از ابتدا درست باشد، بخش خدمات سرور و زیرساخت همین نوع کارها را پوشش میدهد.
نکات کلیدی
- SNI مختص اتصال است؛ Host مختص درخواست
- پشت پروکسیهای دارای connection pooling، لایه ۷ و Host را مرجع بگیرید
- سرور پیشفرض باید رد کند، نه اینکه به یک سرویس واقعی برسد
- تست چنددامنهای موازی از مسیر CDN بنویسید
جمعبندی
SNI میگوید اتصال برای چه نامی باز شد، نه اینکه هر درخواستی که بعداً روی آن میآید برای چه نامی است. وقتی واسطهای اتصالها را به اشتراک میگذارد، فقط هدر Host در هر درخواست قابل اتکاست؛ پس تصمیم مسیریابی را به لایهای ببرید که آن را میبیند.
منابع
- RFC 6066 — Transport Layer Security (TLS) Extensions: Extension Definitionsrfc-editor.org
- MDN — 421 Misdirected Requestdeveloper.mozilla.org
- Nginx — Server namesnginx.org
- Nginx — How nginx processes a requestnginx.org