فهرست کنترل خروج داده: هر تماس بیرونی که یک پشتهٔ مدل زبانی برقرار میکند
شمارش تمام دستههای تماس بیرونی که یک پشتهٔ اپلیکیشن مدل زبانی برقرار میکند — DNS، همگامسازی زمان، اعتبارسنجی گواهی، تلهمتری، هاب مدل، مدیر بسته، فونت، بررسی بهروزرسانی، گزارش خطا — بههمراه کنترلی که هر کدام را متوقف میکند و روشی برای اثبات آن.
چرا این نوشته
فهرستی کامل و دستهبهدسته از تماسهای بیرونی یک پشتهٔ مدل زبانی خودمیزبان، شامل چهار موردی که تقریباً هیچکس نمیشمارد — واکشی OCSP و CRL هنگام اعتبارسنجی TLS، تمدید گواهی ACME، تازهسازی خودکار snap و بستهها، و واکشیهای خودِ اپلیکیشن به نشانیهایی که کاربر تعیین میکند — هر کدام همراه با کنترلی که واقعاً متوقفشان میکند و روشی مبتنی بر فضاینام شبکه برای اثبات اینکه پشته بدون هیچکدامشان کار میکند.
یک پشتهٔ مدل زبانی بهمراتب بیشتر از خودِ مدل به بیرون تماس میگیرد. و معمولاً مدل تنها جزئی است که کسی وارسیاش کرده. هرچه دور آن است — زمان اجرا، مدیر بستهای که نصبش کرده، کتابخانهٔ گواهی که TLSاش را اعتبارسنجی میکند، داشبوردی که پایشش میکند، فونتی که مرورگر برای نمایش خروجیاش بارگذاری میکند — نظر خودش را دربارهٔ رسیدن به اینترنت دارد، و بیشتر این نظرها پیشفرضهایی هستند که هیچکس انتخابشان نکرده.
آنچه میخوانید همان فهرست است. هر دسته میگوید چه چیزی تماس را برقرار میکند، روی سیم چه شکلی دارد، و کدام کنترل متوقفش میکند. ترتیب، تقریباً همان ترتیبی است که هر کدام غافلگیرتان میکنند.
یک نکتهٔ واژگانی: «egress» در فارسی معادل جاافتادهای ندارد و معمولاً «ترافیک خروجی» ترجمهاش میکنند، که دقیق نیست — چون بحث بر سر حجم ترافیک نیست، بر سر وجود مقصد بیرونی است. اینجا خروج شبکهای (egress) را به کار میبریم، و برای هر درخواست منفرد تماس بیرونی.
دستهها، در یک جدول
در یک استقرار خودمیزبان معمولی مدل زبانی، یازده دستهٔ تماس بیرونی وجود دارد. نُه دسته را میشود کاملاً حذف کرد؛ دو دسته به جایگزین نیاز دارند نه به مسدودسازی.
| # | دسته | مقصد معمول | کنترل | حذف یا جایگزینی |
|---|---|---|---|---|
| 1 | تفکیک نام دامنه | حلکنندههای عمومی، فورواردرهای بالادستی | حلکنندهٔ داخلی مرجع، بدون هیچ فورواردر | جایگزینی |
| 2 | همگامسازی زمان | *.pool.ntp.org، NTP سازنده | منبع داخلی stratum-1 یا stratum-2 | جایگزینی |
| 3 | بررسی ابطال گواهی TLS | پاسخدهندههای OCSP، نقاط توزیع CRL | مرجع صدور داخلی، با بررسی ابطال پیکربندیشدهٔ صریح | جایگزینی |
| 4 | صدور و تمدید گواهی | نقاط پایانی دایرکتوری ACME | مرجع صدور داخلی با مسیر صدور بلندمدت | جایگزینی |
| 5 | تلهمتری و تحلیل استفاده | نقاط آمار سازنده، PostHog، Segment | تنظیم انصراف بهعلاوهٔ مسدودسازی شبکه | حذف |
| 6 | بررسی نسخه و بهروزرسانی | API انتشار سازنده، کاتالوگ افزونهها | پرچم پیکربندی بهعلاوهٔ مسدودسازی شبکه | حذف |
| 7 | گزارش خطا و کرش | DSN های Sentry، نقاط کرش سازنده | خالیکردن DSN؛ میزبانی داخلی جمعکننده | حذف یا جایگزینی |
| 8 | واکشی از هاب مدل | Hugging Face، رجیستری Ollama، آینههای S3 | متغیرهای آفلاین بهعلاوهٔ رجیستری محلی | حذف |
| 9 | واکشی بسته و ایمیج | PyPI، npm، رجیستری کانتینر، آینهٔ توزیع | آینهٔ داخلی؛ هیچ چیز در زمان اجرا کشیده نشود | جایگزینی |
| 10 | واکشی داراییهای سمت مرورگر | CDN فونت و اسکریپت، کاشی نقشه، فاوآیکون | میزبانی داخلی همهچیز؛ تحمیل با CSP | حذف |
| 11 | واکشیهای خودِ اپلیکیشن | هر جایی که کاربر یا مدل نامش را بیاورد | یک گلوگاه خروج واحد با فهرست مجاز | حذف |
DNS اولین کنترل است و همانی که نشت میکند
DNS نخستین چیزی است که در استقرار بدون خروج باید درست شود، چون حلکنندهای که فورواردر بالادستی دارد هر نام میزبانی را که هر جزئی از سامانه تا ابد به زبان بیاورد به یک پرسوجوی بیرونی تبدیل میکند — از جمله نامهایی که فقط در مسیر خطا ظاهر میشوند. یک اتصال HTTPS مسدودشده همچنان جستوجوی DNS پیش از خودش را میفرستد، و آن جستوجو نام را با خودش میبرد.
یک حلکنندهٔ داخلی اجرا کنید که برای زونهای شما مرجع باشد و برای بقیه NXDOMAIN برگرداند، بدون هیچ فورواردر پیکربندیشده. بعد از داخل وارسیاش کنید، چون ممکن است جزئی از سامانه کتابخانهٔ حل نام خودش یا سرور DNS سیمکشیشدهٔ خودش را داشته باشد:
# Should return the internal address. If anything answers for a public name,
# something is forwarding.
dig +short registry.ollama.ai
dig +short stats.grafana.org
dig +short huggingface.co
حالت شکستی که ارزش نامبردن دارد: بعضی زمان اجراهای جاوا نتایج موفق DNS را برای همیشه در حافظهٔ نهان نگه میدارند (networkaddress.cache.ttl)، و بعضی باینریهای Go بسته به نحوهٔ ساختهشدنشان اصلاً حلکنندهٔ سیستم را دور میزنند. هر جزء را جداگانه آزمون کنید، نه فقط میزبان را.
همگامسازی زمان باید جایگزین شود، نه مسدود
NTP تنها دستهای است که مسدودکردنش مسئلهای بدتر از خود ترافیک میسازد. اعتبارسنجی گواهی، انقضای توکن، همبستگی لاگها و هر گام تأیید امضا همگی به ساعت وابستهاند، و محیط ایزوله رانش زمانی پیدا میکند. systemd-timesyncd و chrony هر دو با استخر NTP سازنده بهصورت پیشفرض پیکربندی شدهاند، پس کار این است که مقصدشان را عوض کنید، نه اینکه حذفشان کنید.
# /etc/chrony/chrony.conf — internal source only, no pool directive.
server time.internal.example iburst
makestep 1.0 3
rtcsync
اگر اصلاً منبع زمان داخلی ندارید، این شکافی است که باید پیش از استقرار پشتهٔ مدل زبانی پر شود، نه بعد از آن. یک دستگاه stratum-1 با انضباط GPS در برابر هزینهٔ اشکالزدایی خطاهای «توکن منقضی» روی کلاستری که ساعتهایش از هم فاصله گرفتهاند، ارزان است.
اعتبارسنجی گواهی خودش به بیرون زنگ میزند، و تقریباً هیچکس آن را در فهرست نمیآورد
اعتبارسنجی TLS خودش یک دستهٔ مستقل از تماس بیرونی است، و همان دستهای است که در هیچکدام از فهرستهای بدونخروجی که خواندهایم نیامده. دو سازوکار در کارند. OCSP یک درخواست HTTP زنده به پاسخدهندهٔ مرجع صدور میزند تا بپرسد آیا گواهی باطل شده یا نه. نقاط توزیع CRL نشانیهای HTTP هستند که داخل خود گواهی جاسازی شدهاند و برای دانلود فهرست ابطال واکشی میشوند.
هر دو با اعتبارسنجی گواهیای که یک مرجع عمومی صادر کرده فعال میشوند. اگر هر سرویس داخلی گواهی صادرشده از مرجع عمومی ارائه کند، هر کلاینتی که آن را اعتبارسنجی میکند ممکن است تلاش کند به یک پاسخدهندهٔ عمومی برسد — و در محیط مسدود، نشانهٔ قابل مشاهده یک وقفهٔ چندثانیهای در اتصال است، نه یک خطا، چون بیشتر پشتهها شکست نرم دارند.
راهحل ساختاری است: هر گواهی داخلی را از یک مرجع صدور داخلی صادر کنید، CRL را روی یک نشانی داخلی منتشر کنید، و کلاینتها را طوری پیکربندی کنید که همانجا را بررسی کنند. صرفاً پاسخدهندهها را مسدود نکنید و گواهیهای عمومی را سر جایشان نگذارید؛ آنوقت وقفهها را میگیرید بدون آنکه خاصیت امنیتیاش را داشته باشید.
همین استدلال دربارهٔ ACME هم صادق است. سرویس داخلیای که گواهیاش را از یک ارائهدهندهٔ عمومی ACME گرفته، هر شصت روز به دسترسی بیرونی نیاز دارد وگرنه از کار میافتد — و شکست تمدید، ناهمزمان و بیصداست. مرجع صدور داخلی با صدور چندساله پاسخ درست محیط ایزوله است، و کل هزینهٔ عملیاتیاش توزیع یک گواهی ریشه است.
انصراف از تلهمتری سند است، نه کنترل
هر جزء مهم یک پشتهٔ مدل زبانی بهطور پیشفرض آمار استفاده جمع میکند و هر کدام راه انصراف خودش را دارد. همه را تنظیم کنید — و بعد مسدودسازی شبکه را کنترل واقعی بدانید، چون یک پرچم میتواند در یک انتشار جزئی پسرفت کند و کسی متوجه نشود.
| جزء | راه انصراف | یادداشت |
|---|---|---|
| vLLM | VLLM_NO_USAGE_STATS=1، VLLM_DO_NOT_TRACK=1، DO_NOT_TRACK=1، یا فایل $HOME/.config/vllm/do_not_track | دادهٔ جمعشده را میشود پیشاپیش در ~/.config/vllm/usage_stats.json دید |
| کتابخانههای Hugging Face | HF_HUB_DISABLE_TELEMETRY=1 | با HF_HUB_OFFLINE=1 هم بهطور ضمنی خاموش میشود |
| Grafana | [analytics] reporting_enabled = false، check_for_updates = false، check_for_plugin_updates = false | گزارشهایی هست از انتشارهایی که این پرچمها را کامل رعایت نکردهاند |
| قرارداد عمومی | DO_NOT_TRACK=1 | مجموعهٔ روبهرشدی از ابزارهای خط فرمان طبق قرارداد console do-not-track رعایتش میکنند |
دو سطر از این جدول درس اصلی را در خود دارند. مستندات آمار استفادهٔ vLLM بهطور غیرعادی خوب است — دقیقاً میگوید چه چیزی جمع میشود و سه راه برای متوقفکردنش میدهد. پرچمهای Grafana هم به همان اندازه خوب مستند شدهاند، و گزارشهای عمومی از انتشارهای مشخصی وجود دارد که در آنها زیرسامانهٔ افزونهها با وجود check_for_plugin_updates = false باز هم به grafana.com دست دراز کرده است. هیچکدام از این دو سازنده بدرفتاری نکردهاند. مسیر انصراف صرفاً آن مسیری از کد نیست که کسی برایش آزمون پسرفت بنویسد.
هاب مدل و زمان اجرای استنتاج
ترافیک هاب مدل هم آسان حذف میشود و هم آسان نیمهحذف میماند، چون واکشی فقط هنگام نبود در حافظهٔ نهان اتفاق میافتد. سرویسی که یک ماه با حافظهٔ نهان گرم اجرا شده کاملاً آفلاین به نظر میرسد — تا لحظهای که کسی یک کاراکتر از شناسهٔ مدل را عوض کند.
# Set at the unit level, not in a shell profile.
Environment=HF_HUB_OFFLINE=1
Environment=TRANSFORMERS_OFFLINE=1
Environment=HF_HOME=/srv/models/hf
HF_HUB_OFFLINE=1 هنگام بارگذاری مدل جلوی تماسهای HTTP با هاب را میگیرد، طبق مرجع متغیرهای محیطی huggingface_hub. در Ollama مدلها بهطور پیشفرض روی registry.ollama.ai حل میشوند؛ مدل را بهصورت محلی از یک فایل GGUF که خودتان آوردهاید با یک Modelfile بسازید و زیر یک فضاینام داخلی برچسب بزنید تا هیچوقت چیزی به بالادست حل نشود. رفتار بیرونی همان نسخهای را که مستقر میکنید با ضبط بستهٔ شبکه وارسی کنید، نه از روی یک پست وبلاگ — متغیرهای محیطیای که ظاهراً کنترلش میکنند بین انتشارها فرق دارند، و چند تای پرتکرارشان اصلاً وجود ندارند.
مدیر بسته متعلق به زمان ساخت است، نه زمان اجرا
قویترین کنترل اینجا یک آینه نیست. یک قاعده است: هیچ چیز در زمان اجرا نصب نمیشود. کانتینری که هنگام بالا آمدن pip install میزند، نقطهٔ ورودیای که npm ci صدا میکند، چارت Helmی که یک افزونه میکشد — هر کدام یک سرویس در حال اجرای تولید را به یک ساخت تبدیل میکنند، و ساخت طبق تعریف به خروج شبکهای نیاز دارد.
وابستگیها را در زمان ساخت و در سمت متصل داخل ایمیج بپزید، رجیستریها را داخلی آینه کنید، و پیکربندی زمان اجرا را طوری بگذارید که نبودِ چیزی در حافظهٔ نهان با صدای بلند شکست بخورد:
# /etc/pip.conf
[global]
index-url = https://pypi.internal.example/simple
trusted-host = pypi.internal.example
no-cache-dir = false
معادل سطح توزیع را جداگانه وارسی کنید. میزبانهای Ubuntu هم unattended-upgrades اجرا میکنند و هم snapd؛ و snapd بهطور خاص طبق زمانبندی خودش تازهسازی میکند و در برابر غیرفعالشدن دائمی مقاومت نشان میدهد. یا از ایمیج حذفش کنید یا یک مسدودسازی دائمی و قابل مشاهده در لاگ خروجتان را بپذیرید.
داراییهای سمت مرورگر، رایجترین نشت واحدند
پرتکرارترین نشت خروجی در یک استقرارِ از هر جهت محتاط، یک فونت وب است. یک <link> به CDN فونت یعنی هر نشست مرورگر در کل سازمان به یک طرف سوم درخواست میزند و ارجاعدهنده را با خودش میبرد، آن هم روی صفحهای که در بقیهٔ اجزا کاملاً داخلی است. در هیچ حسابرسی سمت سرور دیده نمیشود، چون درخواست را مرورگر میزند نه سرور شما.
ما دقیقاً به همین دلیل همهٔ فونتها را خودمان میزبانی میکنیم. این تصمیمی دربارهٔ وابستگی است، نه دربارهٔ کارایی: اپلیکیشنی که بدون یک طرف سوم نمیتواند رابط خودش را ترسیم کند، اپلیکیشن خودبسندهای نیست. همین دربارهٔ مجموعهآیکونها، کاشیهای نقشه، اسکریپتهای تحلیل و فاوآیکونهای میزبانیشده در بیرون هم صادق است.
کاری کنید که مرورگر تحمیلش کند، تا برنامهنویس بعدی نتواند دوباره واردش کند:
Content-Security-Policy:
default-src 'self';
font-src 'self';
img-src 'self' data:;
connect-src 'self';
script-src 'self';
frame-ancestors 'none';
base-uri 'self'
یک سیاست امنیت محتوا (CSP) با default-src 'self' یک ارجاع تصادفی به CDN را از نشتی بیصدا به یک خطای کنسول در زمان توسعه تبدیل میکند. کل ارزشش همین است: شکست برای همان کسی که ایجادش کرده قابل مشاهده میشود.
تماسهای بیرونی خودِ اپلیکیشن به یک گلوگاه نیاز دارند
آخرین دسته سختترین است، چون این تماسها مشروعاند. یک اپلیکیشن مدل زبانی چیزهایی را واکشی میکند: نشانیای که کاربر چسبانده، وبهوکی که مشتری تنظیم کرده، سندی که یک فراخوانی ابزار نامش را برده. هر کدام درخواستی بیرونی است که مقصدش را کسی جز شما انتخاب کرده — و این دقیقاً تعریف جعل درخواست سمت سرور (SSRF) است.
کنترل، یک گلوگاه واحد است که هر تماس بیرونی HTTP در کل پایگاه کد از آن عبور میکند. دیتاکوپایلوت یکی دارد، و نکته دقیقاً وجود همان یکی است: فقط یک تابع میتواند درخواست بیرونی بزند، پس فقط یک جا برای اعمال سیاست هست و فقط یک جا برای حسابرسی.
# egress.py — the only module permitted to open an outbound connection.
# requests 2.32, urllib3 2.x. The DNS pin is the part that matters.
import ipaddress, socket
from urllib.parse import urlparse
import requests
from urllib3.util import connection
ALLOWED_HOSTS = {"reports.internal.example", "erp.internal.example"}
ALLOWED_SCHEMES = {"https"}
class EgressDenied(Exception):
pass
def _resolve_once(host: str) -> str:
"""Resolve, validate, and return the single address we will connect to."""
infos = socket.getaddrinfo(host, None, proto=socket.IPPROTO_TCP)
addrs = {i[4][0] for i in infos}
if not addrs:
raise EgressDenied(f"no address for {host}")
for a in addrs:
ip = ipaddress.ip_address(a)
if (ip.is_private or ip.is_loopback or ip.is_link_local
or ip.is_reserved or ip.is_multicast):
raise EgressDenied(f"{host} resolves to non-routable {a}")
return sorted(addrs)[0]
def fetch(url: str, *, timeout: float = 10.0) -> requests.Response:
parts = urlparse(url)
if parts.scheme not in ALLOWED_SCHEMES:
raise EgressDenied(f"scheme {parts.scheme!r} not permitted")
if parts.hostname not in ALLOWED_HOSTS:
raise EgressDenied(f"host {parts.hostname!r} not on the allowlist")
pinned = _resolve_once(parts.hostname)
# Connect to the address we validated, not to whatever DNS answers next.
# Without this, an attacker-controlled record can pass validation and then
# resolve to 169.254.169.254 on the connection attempt.
original = connection.create_connection
def pinned_connection(address, *args, **kwargs):
_host, port = address
return original((pinned, port), *args, **kwargs)
connection.create_connection = pinned_connection
try:
return requests.get(url, timeout=timeout, allow_redirects=False)
finally:
connection.create_connection = original
سه ویژگی این تابع مهماند و هر کدام به یک دور زدن واقعی پاسخ میدهند. allow_redirects=False جلوی این را میگیرد که میزبانی در فهرست مجاز شما را به میزبانی ممنوع تغییر مسیر دهد. سنجاقکردن نشانی، پنجرهٔ بازپیوند DNS (DNS rebinding) میان اعتبارسنجی و اتصال را میبندد. و فهرست مجاز مجموعهای از نام میزبان است، نه یک عبارت باقاعده — چون هر فیلتر SSRF که به شکل عبارت باقاعده نوشته شده، سرانجام با نشانیای شکست خورده که دو کتابخانه آن را دو جور تجزیه کردهاند.
اثباتش کنید: رد پیشفرض، بعد اجرای پشته بدون هیچ مسیر
دو سازوکار این را از یک فهرست کنترل به یک خاصیت تبدیل میکنند. اولی سیاست خروج با رد پیشفرض روی میزبان است، تا هر چیزی که در بالا فهرست نشده بهجای موفقیت بیصدا، با شکست قابل مشاهده روبهرو شود:
# /etc/nftables.conf
table inet filter {
chain output {
type filter hook output priority 0; policy drop;
ct state established,related accept
oif "lo" accept
ip daddr 10.0.0.0/8 accept comment "internal networks"
ip daddr 172.16.0.0/12 accept
ip daddr 192.168.0.0/16 accept
udp dport 53 ip daddr 10.10.0.53 accept comment "internal resolver"
udp dport 123 ip daddr 10.10.0.10 accept comment "internal NTP"
log prefix "EGRESS-DROP " limit rate 10/minute
}
}
قاعدهٔ log در انتها، نیمهٔ مفید ماجراست. یک هفته در محیط پیشتولید اجرایش کنید و رهاشدهها را بخوانید: آن لاگ، فهرست واقعی چیزهایی است که پشتهٔ شما تلاش میکند به آنها برسد، و اجزایی در آن خواهد بود که نمیدانستید اصلاً نصباند.
سازوکار دوم یک آزمون است. کل پشته را در یک فضاینام شبکه (network namespace) بدون مسیر پیشفرض اجرا کنید و ادعا کنید که هنوز به یک درخواست پاسخ میدهد:
# no-egress-test.sh — the stack must work with no route off the host.
set -euo pipefail
sudo ip netns add offline
sudo ip netns exec offline ip link set lo up
# Only loopback exists in this namespace. No default route, no gateway.
sudo ip netns exec offline env \
HF_HUB_OFFLINE=1 TRANSFORMERS_OFFLINE=1 DO_NOT_TRACK=1 \
./run-stack.sh &
sleep 30
sudo ip netns exec offline curl -fsS \
-X POST http://127.0.0.1:8000/v1/chat/completions \
-H 'content-type: application/json' \
-d '{"model":"internal/analyst","messages":[{"role":"user","content":"ping"}]}'
sudo ip netns delete offline
این را در یکپارچهسازی پیوسته بگذارید. استقرار بدون خروجی که فقط با خواندن فایلهای پیکربندی تأیید شده باشد، استقراری است که در بهروزرسانی بعدی وابستگیها یک وابستگی بیرونی به دست میآورد و هیچکس نمیفهمد — تا وقتی تیم شبکه بپرسد چرا یک میزبان تولید دارد از یک حلکنندهٔ عمومی پرسوجو میکند.
جایی که این توصیه دیگر کار نمیکند
این فهرست کنترل، استقراری خودبسنده را هدف گرفته که تنها وابستگی بیرونیاش عمدی است. در چهار حالت بهدرستی منتقل نمیشود.
اگر از API مدل میزبانیشده استفاده میکنید، خروج شبکهای خودِ محصول است. آنوقت کار حذف نیست، محدودسازی است: یک مقصد، یک گلوگاه، سنجاقکردن TLS، و یک سند مستند جریان داده — تمرینی متفاوت با آنچه اینجا آمد.
اگر محیط تحت مقررات با پراکسی خروجی الزامی دارید، بهجای «مسدود کن» بگذارید «از پراکسی عبور بده و لاگ کن»، و گلوگاه و CSP را نگه دارید. بخشهای DNS، NTP و گواهی بدون تغییر معتبر میمانند.
اگر روی Kubernetes مدیریتشده اجرا میکنید، چند تا از این کنترلها به لایهٔ دیگری تعلق دارند — NetworkPolicy، دروازههای خروج و پیکربندی DNS در سطح نود — و نمونهٔ nftables سطح میزبانِ بالا را خودِ پلتفرم بازنویسی خواهد کرد.
و اگر سازمان شما به دلیل انطباق به شفافیت گواهی عمومی یا زیرساخت ابطال عمومی وابسته است، بخش گواهی توصیهای است که نمیتوانید بپذیرید. در آن صورت استثنا را صریحاً مستند کنید، بهجای اینکه بهشکل حفرهای بیتوضیح در سیاستی که در بقیهٔ اجزا بسته است رهایش کنید. نقشهٔ دقیقِ یک شکاف عمدی از نموداری که ادعا میکند هیچ شکافی نیست، ارزش بیشتری دارد.