پرش به مطلب اصلی

بخش ۴: packet line‌ها و تونل‌های packet

WaterWall علاوه بر stream‌های معمول، با packet‌های layer 3 هم کار می‌کند؛ مثلاً در دستگاه TUN، مسیرهای شبیه WireGuard و پردازش raw packet. مدل packet هم از line_t استفاده می‌کند، اما چرخه‌ی عمر آن کاملاً متفاوت است. اگر packet line را مثل line یک اتصال در نظر بگیرید، یکی از خطرناک‌ترین اشتباه‌های این بخش از کد را مرتکب شده‌اید.

قبل از تغییر کدی که زیر یک node از نوع layer 3 اجرا می‌شود، این بخش را بخوانید. مرجع اصلی رفتار، این فایل‌ها هستند: ww/net/chain.c، ‏ww/net/packet_tunnel.c، ‏ww/net/line.h و خود تونل‌های packet.

packet line چیست؟​

packet line یک line_t واقعی است، اما برخلاف line اتصال:

  • برای هر chain دارای node از نوع layer 3، یک بار به‌ازای هر worker تخصیص می‌یابد؛
  • میان همه‌ی packet‌هایی که آن worker پردازش می‌کند مشترک است؛
  • به چرخهٔ عمر اتصال TCP/UDP وابسته نیست؛
  • در زمان اجرای عادی بسته نمی‌شود؛
  • فقط زمانی نابود می‌شود که خود chain نابود شود.

کد منبع در این مورد صریح است. در tunnelchainFinalize() از ww/net/chain.c، اگر chain node از نوع packet داشته باشد، برای هر worker یک packet line ساخته می‌شود (در اینجا پاک‌سازی pool‌های پیرامون کد حذف شده است):

line_t **packet_lines = memoryAllocate(sizeof(*packet_lines) * tc->workers_count);
if (! packet_lines)
{
startupFailureRecord(1);
return;
}

// ... ساخت و اعتبارسنجی Pool مربوط به Line همهٔ Workerها ...
tc->packet_lines = packet_lines;

for (wid_t i = 0; i < tc->workers_count; i++)
{
if (tc->contains_packet_node)
{
packet_lines[i] = lineCreateForWorker(0, tc->line_pools, i);
}
}

این line‌ها فقط در tunnelchainDestroy() از بین می‌روند:

for (uint32_t i = 0; i < tc->workers_count; i++)
{
if (tc->packet_lines && tc->packet_lines[i])
{
lineDestroy(tc->packet_lines[i]);
}
}

packet line هر worker را با tunnelchainGetWorkerPacketLine(chain, wid) بگیرید.

packet line را هرگز در زمان اجرا نابود نکنید

در کارکرد عادی روی packet line تابع lineDestroy() را فراخوانی نکنید و هیچ منطقی طراحی نکنید که انتظار داشته باشد packet line پس از Finish دوباره ساخته شود. برخی adapter‌های packet مانند TunDevice و UdpStatelessSocket در نسخه‌های Debug با assert بررسی می‌کنند که packet line پس از forwardشدن یک packet همچنان زنده باشد. اگر تغییر شما اجازه می‌دهد packet line هنگام اجرا بمیرد، تقریباً به‌طور قطع دچار خطاست.

دریافت Finish در زمان اجرای packet line​

بخش ۲ الزام می‌کند که handler مربوط به Finish در مالک یک line عادی، هنگام بازگشت شرط ! lineIsAlive(line) را برقرار کرده باشد. packet line‌ها بدون هیچ استثنایی از این شرط مستثنا هستند. مالک packet line خود chain است، نه هیچ‌یک از تونل‌ها، و فقط tunnelchainDestroy() اجازه دارد آن را نابود کند. این بخش از قرارداد مطلق است: handler هر کار دیگری که انجام دهد، هرگز نباید روی packet line تابع lineDestroy() را فراخوانی کند.

با این حال، معنای رسیدن Finish به یک packet line کاملاً به بخش دریافت‌کننده در chain، جهتی که callback از آن رسیده و نقش handler بستگی دارد. سه دسته وجود دارد. جایگاه تونل در chain اطلاعات مفیدی می‌دهد، اما به‌تنهایی برای انتخاب یکی از این دسته‌ها کافی نیست.

دستهٔ ۱ — anchor چرخه‌ی عمر packet: ردکردن بسته‌شدن ناممکن​

یک adapter یا Bridge مربوط به packet ممکن است state مربوط به packet pipeline آن worker را روی packet line نگه دارد، بی‌آنکه اصولاً بسته‌شدن آن نقش معنایی داشته باشد. منظور از «adapter» در اینجا مرز یا نقطهٔ پایانی packet pipeline است، نه تونلی که مالک line_t باشد. در یک chain درست هیچ بخشی برای آن نقش مشخص Finish نمی‌فرستد؛ بنابراین رسیدن Finish به آن handler و در آن جهت یعنی یکی از همسایه‌ها قرارداد را شکسته است:

  • هرگز lineDestroy() را فراخوانی نکنید؛
  • خطا را آشکارا و فوری گزارش کنید: LOGF(...) و سپس abortProgramNow(1)؛ این سیاست پروژه برای نقض قرارداد packet line است؛
  • آن را به فرایند عادی بستن اتصال تبدیل نکنید؛
  • نابودی واقعی line را به tunnelchainDestroy() بسپارید.

adapter‌هایی که state را روی packet line نگه می‌دارند دقیقاً همین کار را انجام می‌دهند:

void tundeviceTunnelDownStreamFinish(tunnel_t *t, line_t *l)
{
discard t;
LOGF("TunDevice: unexpected downstream Finish on worker packet line %u", (unsigned int) lineGetWID(l));
abortProgramNow(1);
}

نمونه‌ها عبارت‌اند از TunDevice و RawSocket؛ سمت packet در upstream در PacketsToConnection و PacketsToStream؛ سمت packet در downstream در StreamToPackets؛ همچنین PacketSplitStream، حالت‌های packet در TesterClient و TesterServer و نقش packet line در WireGuardDevice. این دسته‌بندی برای هر handler است، نه برچسبی برای کل تونل: همان Bridge ممکن است در سمت دیگر خود مالک line‌های عادی باشد و آن‌ها را به‌شکل معمول ببندد.

تست tests/unittests/tunnels/abort/tunnels_abort_runtime_test.c ثابت می‌کند فراخوانی‌های لنگر پوشش‌داده‌شده بازنمی‌گردند.

دستهٔ ۲ — transform میانی شفاف: forwardکردن چرخهٔ عمر​

تونل میانی‌ای که payload‌های packet را تبدیل می‌کند، صرفاً به‌خاطر این کار مالک یا پایان‌دهندهٔ چرخهٔ عمر line نمی‌شود. چنین تونلی باید Init، ‏Est، ‏Pause، ‏Resume، ‏Finish و هر callback دیگری را که به تبدیل آن مربوط نیست، در همان جهت forward کند:

  • callback‌های upstream با tunnelNextUpStream* ادامه می‌یابند؛
  • callback‌های downstream با tunnelPrevDownStream* ادامه می‌یابند؛
  • callback دریافت‌شده هرگز به‌سمت فرستندهٔ خود بازتابانده نمی‌شود.

PingClient این callback‌های عبوری چرخهٔ عمر را از packettunnelCreate() به ارث می‌برد؛ PingServer نیز forwarding صریح و معادل آن‌ها را تنظیم می‌کند. پشتیبانی از payload قرارداد جداگانه‌ای است: هر دو سر Ping v2 برای مالکیت buffer و اعتبارسنجی worker به packet line دقیق همان worker در chain نیاز دارند. ترتیب‌های پشتیبانی‌شده در لبه‌های آن‌ها چنین است:

TunDevice -> PingClient -> RawSocket
RawSocket -> PingServer -> TunDevice

بنابراین یک توپولوژی معمولیِ line عادی مانند UdpListener <-> PingServer <-> UdpConnector ترکیب پشتیبانی‌شده‌ای برای Ping v2 نیست. packettunnelCreate() همچنان callback‌های عبوری استاندارد چرخهٔ عمر را نگه می‌دارد، اما handler پیاده‌سازی‌شدهٔ payload ممکن است نقش محدودتر و دقیق‌تری برای packet line الزام کند.

دستهٔ ۳ — terminal absorber با رفتار مستند: رسیدگی محلی و مستندسازی​

packet line همچنان یک line_t است و یک chain می‌تواند به‌طور مجاز آن را در یک adapter انتهایی سازگار با packet پایان دهد. آن adapter بدون مالک‌شدن، packet line را قرض می‌گیرد و ممکن است سمت خودش را به دلیلی مستند ببندد.

یکی از نمونه‌های موجود، BlackHole فعال پس از PacketSender است:

PacketSender ──(Packet Line آن Worker)──> BlackHole(mode=active)

BlackHole فعال هنگام upstream Init، تابع tunnelPrevDownStreamFinish() را در جهت downstream روی packet line همان worker می‌فرستد و callback به packetsenderTunnelDownStreamFinish() می‌رسد. این اتفاق نقض قرارداد نیست و نباید برنامه را متوقف کند. PacketSender هیچ state مختص line و هیچ prev ندارد؛ پس بسته‌شدن را بدون forwardکردن یا نابودکردن packet line جذب می‌کند. بااین‌حال باید timer و حلقهٔ ارسال آن worker را متوقف کند، زیرا دریافت Finish مقصد upstream را برای همیشه می‌بندد.

adapter‌ای که normal line اتصال می‌خواهد، مقصد packet line نیست. UdpConnector فقط لایه ۴ است؛ مسیر packet باید از PacketSender -> PacketsToStream -> UdpConnector استفاده کند و در سمت دریافت، اگر payload باید به لایه ۳ بازگردد، StreamToPackets قرار گیرد. bridge مالک normal line است و بازسازی آن را بدون بستن packet line پایدار مدیریت می‌کند.

این دسته‌بندی به جهت وابسته است. handler مربوط به downstream در PacketSender جذب‌کننده است، اما handler مربوط به upstream آن همچنان باید با خطای کشنده متوقف شود، زیرا ابتدای chain هیچ prevای ندارد که بتواند به‌شکل مجاز callback‌ای برای آن بفرستد. PacketReceiver نیز جذب‌کنندهٔ عمدی دیگری است: هیچ state مختص line ندارد، ممکن است یکی از دو انتهای chain باشد و تکمیل کار را به‌جای Finish از طریق timer و مسیر گزارش خود اعلام می‌کند.

بسته‌شدن را forward کنید، منابع تحت مالکیت خود را آزاد کنید، نقض قرارداد را گزارش دهید، یا در handler توضیح دهید که هیچ state مختص line و هیچ جهت بعدی ندارید و در نتیجه جذب بسته‌شدن تمام کاری است که باید انجام شود. بدنه‌ای که فقط از دستورهای discard تشکیل شده باشد، حالت آخر را از یک خطا متمایز نمی‌کند.

جذب‌کننده‌های مجاز فعلی عبارت‌اند از BlackHole، یعنی مقصد انتهای chain که هر line را قرض می‌گیرد؛ PacketReceiver، یعنی انتهای chain‌ای که با timer و فایل گزارش می‌دهد؛ و handler مربوط به downstream در PacketSender، یعنی دستهٔ ۳ بالا.

تست tests/line_ownership_policy_test.py همهٔ فایل‌های tunnels/*/{upstream,downstream}/fin.c را پیمایش می‌کند و handler‌ای را که تمام بدنه‌اش فقط دستورهای discard است رد می‌کند، مگر اینکه همراه با دلیلی مکتوب در SILENT_FINISH_ALLOWED آمده باشد. به این ترتیب، forwarding جاافتاده یا بسته‌نشدن line به‌دست مالک نمی‌تواند ظاهری شبیه یک عملیات تهی عمدی پیدا کند.

پس پرسش درست هرگز فقط «آیا این یک packet line است؟» یا «آیا این تونل در انتهای chain است؟» نیست. باید بپرسید: «این handler دقیق و این جهت چه نقشی دارد، تونل چه چیزی را روی این line نگه می‌دارد و چه کسی هنوز منتظر آن است؟»

handler‌های دومنظوره باید ابتدا نقش دقیق line را بررسی کنند​

برخی node‌ها هم‌زمان هم یک packet line پایدار می‌بینند و هم مالک line‌های عادی هستند. handler باید پیش از انجام هر کاری تشخیص دهد کدام‌یک را دریافت کرده است:

  • WireGuardDevice در سمت packet خود تونلی ساخته‌شده با packettunnelCreate() است، اما برای سمت رمزگذاری‌شده نیز به‌ازای هر worker یک line انتقال عادی می‌سازد. Finish روی line انتقال، close عادیِ line تحت مالکیت است: slot را نابود کنید و lineDestroy() را فراخوانی کنید. Finish روی packet line نقض قرارداد است و باید abort کند.
  • PacketsToStream و PacketsToConnection، ‏state مربوط به Bridge را روی packet line نگه می‌دارند و در عین حال مالک line‌های عادی‌ای هستند که پشت آن می‌سازند.

نقش را با کمک chain تشخیص دهید، نه بر اساس نام یا حدس:

if (tunnelchainIsWorkerPacketLine(tunnelGetChain(t), l))
{
LOGF("MyNode: unexpected Finish on worker packet line %u", (unsigned int) lineGetWID(l));
abortProgramNow(1);
}
/* از اینجا به بعد یک Line عادی است؛ شرط پس از اجرای مالک در بخش ۲ را اعمال کنید. */

هر دسته‌بندی عمدی که با نقش line یا جهت تغییر می‌کند باید کنار handler مستند شود و یک تست regression آن را پوشش دهد.

packet line‌ها state مشترک worker هستند​

از آنجا که یک packet line در هر worker به تعداد زیادی packet نامرتبط سرویس می‌دهد، هر چیزی که روی آن ذخیره شود scratch state محلی worker است، نه هویت مختص اتصال. به‌ویژه مراقب این موارد باشید:

  • routing_context.src_ctx و routing_context.dest_ctx
  • هر line state مربوط به تونل که روی packet line ذخیره شده است

این فیلدها با هر packet بازنویسی می‌شوند. آن‌ها را peer، ‏flow یا session پایدار در نظر نگیرید. state ذخیره‌شده روی packet line باید در یکی از این دسته‌ها معنا داشته باشد:

  • state مختص worker؛
  • scratch state مربوط به packet pipeline؛
  • state پل که به سمت packet در worker متصل است.

اگر پشت ترافیک packet واقعاً به چرخه‌ی عمر مختص flow یا اتصال نیاز دارید، در پشت سمت packet، ‏line‌های عادی بسازید؛ packet line را وادار نکنید مانند اتصال رفتار کند.

درخواست checksum متعلق به buffer است​

درخواست ترمیم checksum روی sbuf_t همان packet قرار دارد، نه روی packet line مشترک worker. از sbufSetRecalculateChecksum() و sbufGetRecalculateChecksum() استفاده کنید؛ نگه‌داشتن packet علامت‌خورده یا ارسال آن به worker دیگر نباید درخواست packet نامرتبطی را تغییر دهد. کپی کامل و رشد buffer درخواست را حفظ می‌کنند؛ کپی صرف بایت‌ها به تخصیص دیگر به انتقال صریح درخواست یا ترمیم پیش از copy نیاز دارد. قواعد دقیق نمایش معمولی، reset، کپی و بازه‌ها در بخش ۳ آمده‌اند.

packettunnelTakeChecksumRequest(buf) درخواست همان buffer را می‌گیرد و پاک می‌کند. packettunnelConsumeChecksumRequest(buf) آن را در مرز نهایی مصرف و در صورت نیاز checksum را اصلاح می‌کند. مرز نهایی می‌تواند پیش از ترمیم flag را پاک کند، چون packet ناموفق حذف می‌شود. helperهای مشترک حتی بدون درخواست، ساختار دقیق یک packet IPv4 را بررسی می‌کنند؛ IPv6، دادهٔ ناقص و بایت‌های اضافه پذیرفته نمی‌شوند. false فقط نبود درخواست است و اعتبار checksum را تضمین نمی‌کند.

TunDevice و RawSocket درخواست buffer واقعی را مصرف می‌کنند. پل‌های packet به stream پیش از تبدیل به بایت‌های stream ترمیم را کامل می‌کنند؛ این flag روی wire ارسال نمی‌شود. PTC درخواست را با کپی aligned مرتبط نگه می‌دارد؛ CTP ترمیم را روی کپی بایت‌ها پیش از صف‌گذاری کامل می‌کند. Ping پیش از encapsulation درخواست packet داخلی و پیش از validation/decapsulation درخواست packet بیرونی را مصرف می‌کند؛ درخواست بیرونی به packet داخلی انتقال نمی‌یابد.

decoded affinity خود buffer را همراه درخواست آن جابه‌جا می‌کند و snapshot جداگانه روی line بازنمی‌گرداند. fragment دارای درخواست پیش از staging ترمیم می‌شود؛ این ترمیم checksum header IPv4 را پوشش می‌دهد و transport checksum کل datagram را از یک fragment نمی‌سازد. مالکیت، ترتیب، worker affinity و cancellation ثابت‌اند.

ترمیم incremental فقط تغییر همان transform را پوشش می‌دهد و درخواست قدیمی را حفظ می‌کند. ProtoSwap پیش از encoding، ترمیم در پروتکل بومی را کامل و درخواست را پاک می‌کند؛ refusal یا no-match بایت‌ها و درخواست را دست‌نخورده می‌گذارند. درخواست قدیمی را پس از encoding دوباره تنظیم نکنید. packetهای دارای checksum عمداً خراب می‌توانند بدون درخواست باقی بمانند. این flag از تضمین checksum در metadata خصوصی TUN، حالت trusted و offload/GSO مستقل است؛ trusted بودن درخواست صریح را حذف نمی‌کند و درخواست نیز اعتبارسنجی ساختار یا شروط offload را دور نمی‌زند.

اندازه‌گذاری صف کرنل هنگام startup​

در TunDevice و RawSocket، گزینهٔ kernel-queue-tune فقط boolean می‌پذیرد و پیش‌فرض آن در Linux برابر true و در دیگر پلتفرم‌ها false است. دیگر پلتفرم‌ها تنظیمات معتبر را می‌پذیرند ولی صف‌هایشان را تغییر نمی‌دهند. overrideهای عددی باید عدد صحیح در بازهٔ 1..INT_MAX باشند؛ حذف گزینه به معنی خودکار است و صفر، عدد منفی یا کسری، string، boolean و null رد می‌شوند. حتی با tuning یا capture غیرفعال و روی پلتفرم غیر Linux نیز تنظیمات باید اعتبارسنجی شوند.

تعداد نهایی صف‌های فعال پس از fallback مبنای محاسبه است و مقدار موجودِ بزرگ‌تر هرگز کاهش نمی‌یابد. kernel-queue-length در TunDevice تعداد packet در هر صف است. kernel-queue-packets و kernel-receive-buffer در RawSocket مقدار کل برای هر دستگاه هستند که میان صف‌های فعال تقسیم می‌شوند؛ گزینهٔ دوم تعداد بایت در مقیاس درخواست SO_RCVBUF است. هر override جای هدف خودکار همان منبع را می‌گیرد. سقف خودکار، مقدار صریح یا مقدار موجودِ بزرگ‌تر را محدود نمی‌کند.

RAM profile تنظیم‌شدهpacket در هر صف TUNpacket ترجیحی در هر صف Captureدرخواست receive buffer ترجیحی در هر صف Capture
S1 / S21,0241,0241 MiB
M1 (client)2,0482,0482 MiB
M2 (client-larger)4,0962,0482 MiB
L1 / L2 (server)8,1924,0964 MiB

با N صف فعال، هدف خودکار TUN برابر min(profile_target, 131072 / N) است. Capture بودجهٔ پایهٔ 65,536 packet و 64 MiB درخواست receive buffer را برای هر دستگاه حفظ می‌کند. برای هر منبع، هدف خودکار هر صف برابر max(baseline_total / N, min(profile_preferred, automatic_total_cap / N)) است؛ سقف خودکار کل 131,072 packet و 128 MiB است. مقدار کل صریح، هدف max(baseline_total / N, override_total / N) را انتخاب می‌کند. تقسیم صحیح است. این سیاست از RAM profile تنظیم‌شده استفاده می‌کند، نه تشخیص RAM میزبان یا تغییر پویا با load. هر دستگاه بودجهٔ جدا دارد.

با tuning غیرفعال، طول صف موجود TUN دست‌نخورده می‌ماند و Capture تنظیم پایه را اجرا می‌کند؛ override معتبر اعمال نمی‌شود. این گزینه از skip-sysctl در Capture و misc.tcp-tune در core مستقل است و sysctl سراسری تازه‌ای نمی‌نویسد. FIFO مشترک userspace با بودجهٔ 512 packet و 8 MiB allocation charge، quantum هر callback و backlog خروجی منابع جدا هستند.

خطای tuning یا query باید هشدار بدهد و صف قابل‌استفاده را حفظ کند. اگر مقدار قبلی TUN قابل‌خواندن نیست، نباید تغییری اعمال شود که احتمال کاهش ناشناخته داشته باشد. startup حتی با tuning غیرفعال باید تعداد واقعی صف‌ها و منبع auto/override/disabled را ثبت کند. TUN مقدار قدیم، درخواستی و خوانده‌شدهٔ txqueuelen را گزارش می‌کند؛ مقدار غیرقابل‌خواندن مشخص می‌شود. Capture سقف NFQUEUE تأییدشده با ACK را گزارش می‌کند و مقدار تأییدنشده unknown می‌ماند؛ SO_RCVBUF درخواستی و خوانده‌شده نیز ثبت می‌شود. Linux مقدار حسابداری SO_RCVBUF را دوبرابر گزارش می‌کند و سقف‌های socket میزبان ممکن است درخواست را محدود کنند. این اعداد RSS واقعی یا تعداد بایت payload نیستند. صف بزرگ‌تر می‌تواند burst را جذب کند ولی تأخیر و مصرف حافظه را افزایش دهد؛ بهبود throughput از این تنظیم به‌تنهایی نتیجه نمی‌شود. پیش از افزایش SO_RCVBUF، درخواست را روی یک socket موقت در همان network namespace اعمال کنید و buffer مؤثر آن را بخوانید. تنها اگر probe افزایش را تأیید کرد buffer صف را تغییر دهید؛ در غیر این صورت مقدار موجود را حفظ کنید. socket موقت در همهٔ نتیجه‌ها باید بسته شود. این روش به در دسترس بودن فایل sysctl میزبان در namespace وابسته نیست و مجوز تغییر sysctl یا استفاده از SO_RCVBUFFORCE نمی‌دهد.

دو نوع تونل packet​

همه‌ی تونل‌هایی که با packet line سروکار دارند از یک نوع نیستند. دو دسته‌ی متمایز با قواعد متفاوت وجود دارد.

۱. تونل‌های packet خالص با packettunnelCreate​

برخی تونل‌ها به‌جای tunnelCreate() با packettunnelCreate() ساخته می‌شوند. کد ww/net/packet_tunnel.c:

tunnel_t *packettunnelCreate(node_t *node, size_t tstate_size, size_t lstate_size)
{
if (UNLIKELY(lstate_size != 0))
{
return NULL; // تونل‌های Packet خالص State مختص Line ندارند
}

tunnel_t *t = tunnelCreate(node, tstate_size, 0);
if (! t)
{
return NULL; // سرریز اندازهٔ State یا شکست تخصیص
}

// ... Callbackهای پیش‌فرض الزامی Payload مربوط به Packet را نصب می‌کند ...
return t;
}

نکات اصلی:

  • packettunnelCreate() الزام می‌کند lstate_size == 0 باشد و در صورت نقض این قرارداد، نامعتبر بودن اندازهٔ state یا شکست تخصیص حافظه مقدار NULL برمی‌گرداند. تونل‌های packet خالص state مختص line ندارند و سازندهٔ آن‌ها باید پیش از تعیین callback‌ها یا دسترسی به state تونل، نتیجه را بررسی کند.
  • انتظار می‌رود این تونل‌ها callback‌های payload مربوط به packet را جایگزین کنند.
  • callback‌های پیش‌فرض payload عمداً نامعتبرند: LOGF(...) و سپس abortProgramNow(1) را فراخوانی می‌کنند تا فراموش‌کردن این جایگزینی با خطایی آشکار برنامه را متوقف کند:
static void packettunnelDefaultUpStreamPayload(tunnel_t *self, line_t *line, sbuf_t *payload)
{
LOGF("Unexpected call to default up stream payload for a packet tunnel, "
"this function must be overridden");
abortProgramNow(1);
}
  • Init، ‏Est، ‏Pause، ‏Resume و Finish همان callback‌های عبوری استاندارد و هم‌جهت چرخهٔ عمر را به ارث می‌برند. هر تونل فقط زمانی یکی از آن‌ها را جایگزین می‌کند که نقش دقیق handler آن، مطابق دسته‌بندی بالا، به ردکردن یک close ناممکن در نقش لنگر، ‏forwarding صریح یا جذب انتهایی عمدی نیاز داشته باشد.

نمونه‌ها: IpOverrider، ‏IpManipulator، ‏PingClient، ‏PingServer و WireGuardDevice.

برای تونل‌های packet خالص:

  • بی‌دلیل منطق چرخه‌ی عمر line شبیه اتصال را اضافه نکنید.
  • ‏lstate_size == 0 به‌معنای «فقط packet line» نیست. یک تونل packet از نوع kNodeLayerAnything، ‏line‌های عادی اتصال را نیز عبور می‌دهد.
  • برخی callback‌های پیش‌فرض عمداً برنامه را متوقف می‌کنند؛ دقیقاً بدانید کدام‌ها را باید جایگزین کنید.

۲. پل‌های متکی به packet line با tunnelCreate​

تونل‌های دیگری با tunnelCreate() معمولی ساخته می‌شوند، اما packet line مربوط به worker را به‌عنوان لنگر برای state پل در همان worker به کار می‌گیرند. این تونل‌ها از مرز زمان اجرای مبتنی بر packet و زمان اجرای مبتنی بر اتصال عبور می‌کنند.

نمونه‌ها و state‌ای که نگه می‌دارند:

پلنقش
PacketsToStreamاتصال packet line به یک line سمت stream و مختص worker؛ همان line و یک read buffer را ذخیره می‌کند.
StreamToPacketsاتصال یک stream line به packet line؛ stream line فعال و read buffer را به‌ازای هر worker ذخیره می‌کند.
PacketsToConnectionانتقال ترافیک packet از طریق lwIP به line‌های عادی اتصال WaterWall در پشت سمت packet.
PacketSplitStreamتقسیم ترافیک packet میان مسیرهای stream.

state ذخیره‌شده در اینجا نیز state پل و محلی worker است، نه state مختص اتصال. اگر در پشت ترافیک packet به رفتار قابل‌بستن و مختص اتصال نیاز دارید، به‌جای بستن packet line، ‏line‌های عادی را پشت آن بسازید یا مدیریت کنید.

مقداردهی اولیه‌ی packet line​

مقداردهی packet line با line اتصال متفاوت است.

در chain‌ای که ابتدای آن یک node از نوع packet در layer 3 است، مدیر node هنگام راه‌اندازی روی packet line هر worker یک Init در upstream می‌فرستد؛ یک بار برای هر worker، نه یک بار برای هر packet. بنابراین Init مربوط به packet line یک رویداد راه‌اندازی اولیهٔ worker است، نه بازشدن اتصال.

اما اگر پلی به packet line‌ها متکی باشد و ابتدای chain خودش node از نوع layer 3 نباشد، Init خودکار رخ نمی‌دهد و Bridge باید آن را فعال کند. StreamToPackets دقیقاً همین کار را در onStart() انجام می‌دهد: راه‌اندازی اولیه را در صف همهٔ worker‌ها می‌گذارد تا تونل‌های سمت packet، ‏Init خود را پس از راه‌اندازی ببینند، نه از راه بازورودِ درجا به مسیرهای Init:

static void streamtopacketsQueueWorkerPacketInit(void *worker, void *arg1, void *arg2, void *arg3)
{
discard worker;
discard arg2;
discard arg3;

tunnel_t *t = arg1;
line_t *l = tunnelchainGetWorkerPacketLine(tunnelGetChain(t), getCurrentWorkerWID());

if (UNLIKELY(! lineCallWithRef(l, tunnelNextUpStreamInit, t)))
{
LOGF("StreamToPackets: worker packet line died during packet-side init");
abortProgramNow(1);
}
}

void streamtopacketsTunnelOnStart(tunnel_t *t)
{
for (wid_t wi = 0; wi < getWorkersCount(); wi++)
{
if (UNLIKELY(
sendWorkerMessageForceQueueWithCleanup(wi, streamtopacketsQueueWorkerPacketInit, NULL, t, NULL, NULL) !=
kWorkerMessageSubmitAccepted))
{
LOGF("StreamToPackets: failed to admit required packet-side Init on worker %u", (unsigned int) wi);
startupFailureRecord(1);
return;
}
}
}

بنابراین هنگام ساخت یا تغییر Bridge مربوط به packet، فرض نکنید packet line از قبل به‌صورت خودکار Init شده است. بررسی کنید آیا Init را مدیر node می‌فرستد، چون ابتدای chain از نوع layer 3 است، یا تونل شما باید در onStart() آن را فعال کند.

جهت تونل‌های جفت: قاعده‌ی PingClient / PingServer​

نقش transform و جهت forward شدن callback دو تصمیم جدا هستند. Ping wire v2 دو نشست مستقل Echo دارد:

دادهٔ Client: PingClient -- Echo Request --> PingServer
PingClient <-- Echo Reply دقیق -- PingServer

دادهٔ Server: PingClient <-- Echo Request -- PingServer
PingClient -- Echo Reply دقیق --> PingServer
  • مسیر upstream در PingClient و مسیر downstream در PingServer، دادهٔ محلی خود را در Echo request نوع 8 قرار می‌دهند. همتای آن‌ها request را در callback متناظر decode می‌کند و packet داخلی را در همان جهت callback ادامه می‌دهد.
  • Reply نوع 0 مربوط به request دریافت‌شده، پیش از تحویل request اصلی به فرستنده بازمی‌گردد. Reply فقط نقش تأیید را دارد؛ فرستنده Reply منطبق را مصرف می‌کند و payload آن را تحویل نمی‌دهد.
  • correlation و جلوگیری از replay بر عهدهٔ یک tracker همگام‌شده و دارای ظرفیت محدود برای هر node از نوع Ping است. flow affinity دستگاه تعیین می‌کند کدام packet line یک callback را اجرا کند؛ WID آن line هویت correlation میان request و Reply نیست.
  • دریافت‌کنندهٔ request دو buffer مجزا منتشر می‌کند. clone ساخته‌شده برای Reply به‌سمت فرستندهٔ request برمی‌گردد، درحالی‌که نسخهٔ اصلی decode‌شده در همان جهتی ادامه می‌یابد که request از آن دریافت شده بود.
  • بنابراین handler مربوط به request در upstream در PingServer، ‏clone مربوط به Reply را با tunnelPrevDownStreamPayload() و نسخهٔ اصلی decode‌شده را با tunnelNextUpStreamPayload() می‌فرستد. handler مربوط به request در downstream در PingClient، ‏clone را با tunnelNextUpStreamPayload() و نسخهٔ اصلی decode‌شده را با tunnelPrevDownStreamPayload() می‌فرستد.

بازگرداندن خود request دریافت‌شده به‌سمت فرستنده، بازتابی نامعتبر است. ارسال Echo Reply دقیق و جداگانه‌ای که در جهت مخالف ساخته شده، الزام wire protocol است. به همین ترتیب، encapsulateکردن نسخهٔ اصلی در callback مربوط به upstream در PingServer باعث می‌شود داده دو بار transform شود.

روش کار برای هر تونل packet:

  1. جریان packet را همراه با پیکان‌ها رسم کنید.
  2. مرحله‌ی encode/decode را در جهت callback متناسب با جریان قرار دهید.
  3. فقط برای forward در upstream از tunnelNextUpStreamPayload() و فقط برای forward در downstream از tunnelPrevDownStreamPayload() استفاده کنید.
  4. packet line را line مختص اتصال در نظر نگیرید.
  5. در runtime روی packet line تابع lineDestroy() را صدا نزنید.
  6. به تونل packet خالص چرخه‌ی عمر stream-style اضافه نکنید، مگر آنکه طراحی‌اش واقعاً به آن نیاز داشته باشد.

تست تونل‌های packet​

تست تونل packet باید جهت واقعی موردنظر را پوشش دهد. توپولوژی استاندارد تست Ping یک chain مستقیم است:

TesterClient(packet-mode=true)
-> PingClient
-> PingServer
-> TesterServer(packet-mode=true)

درخواست upstream باید ابتدا Reply دقیق بگیرد و سپس پیش از رسیدن به TesterServer بازیابی شود. دادهٔ جهت معکوس باید Echo request جداگانه باشد، Reply دقیق بگیرد و پیش از رسیدن به TesterClient بازیابی شود. پوشش تستی‌ای اضافه کنید که اگر PingServer در upstream به‌جای decode دوباره encapsulate کند، یک Reply دو بار تحویل شود یا دادهٔ server در Reply مربوط به request اصلی قرار گیرد، با شکست روبه‌رو شود.

قرارداد Finish در packet line پوشش تستی جداگانه‌ای دارد و هر handler جدید در سمت packet باید به آن افزوده شود:

  • tests/unittests/tunnels/abort/tunnels_abort_runtime_test.c، ‏handler‌های Finish مربوط به adapter‌های لنگر را واقعاً در یک فرایند فرزند فراخوانی می‌کند و الزام می‌کند فرایند فرزند به‌جای بازگشت از handler، متوقف شود.
  • tests/unittests/tunnels/packet_io/packetsender_orderly_shutdown_test.c ثابت می‌کند وقتی یک payload به‌شکل re-entrant یک Finish در downstream تولید می‌کند، PacketSender بدون نابودکردن packet line همان worker، ‏timer در انتظار را لغو می‌کند و دستهٔ آماده‌ای شامل چند packet را متوقف می‌سازد.
  • tests/line_ownership_policy_test.py در صورتی با شکست روبه‌رو می‌شود که یک فایل fin.c به بازگشتی بدون توضیح تبدیل شود یا ثبت CTest مربوط به یکی از تست‌های قرارداد حذف شود.

خلاصهٔ قواعد تغییرات مبتنی بر packet​

  • packet line‌ها را هنگام اجرا نبندید و هرگز روی آن‌ها lineDestroy() را فراخوانی نکنید.
  • شرط پس از اجرای Finish برای مالک که در بخش ۲ آمده، برای packet line‌ها صدق نمی‌کند.
  • هر handler و جهت مربوط به Finish در packet line را در یکی از سه نقش قرار دهید: anchor یک close ناممکن را با LOGF و abortProgramNow(1) رد می‌کند؛ transform میانی شفاف آن را در همان جهت forward می‌کند؛ و جذب‌کنندهٔ انتهایی عمدی توضیح می‌دهد چرا چیزی برای forwardکردن یا آزادکردن وجود ندارد.
  • رفتار PacketSender به جهت بستگی دارد: downstream، ‏close مجاز رسیده از یک مقصد packet line مانند BlackHole فعال را جذب می‌کند، اما upstream همچنان fatal است. PacketReceiver نیز یک terminal absorber با رفتار مستند است.
  • handler‌ای که ممکن است هم packet line و هم line عادی دریافت کند، باید پیش از هر cleanup بر اساس نقش دقیق آن‌ها با tunnelchainIsWorkerPacketLine() شاخه‌بندی کند.
  • packettunnelCreate() فقط به‌معنای lstate_size == 0 است. چند node ساخته‌شده با آن از نوع kNodeLayerAnything هستند و line‌های عادی اتصال را نیز عبور می‌دهند؛ به همین دلیل callback‌های چرخهٔ عمر آن، callback‌های عبوری ساده باقی می‌مانند.
  • handler مربوط به Finish که کاری انجام نمی‌دهد باید دلیل آن را روشن کند: Finish/بسته‌شدن را forward کنید، منابع را آزاد کنید، نقض قرارداد را گزارش دهید، یا مستند کنید که هیچ state مختص line و هیچ جهت بعدی ندارید.
  • منطق عادی Finish مختص اتصال را روی packet line اجرا نکنید، مگر آنکه قرارداد نقش دقیق handler صریحاً آن را الزام کند.
  • state مربوط به packet line را state محلی و مشترک worker در نظر بگیرید.
  • routing context را scratch state تغییرپذیر worker بدانید، نه هویت پایدار session.
  • درخواست checksum را فقط روی buffer همان packet نگه دارید؛ نگه‌داری یا تحویل packet نباید درخواست buffer دیگری را تغییر دهد.
  • برای چرخه‌ی عمر مختص flow، پشت سمت packet یک line عادی بسازید.
  • تونل ساخته‌شده با packettunnelCreate() هیچ state مختص line ندارد و lstate_size == 0 است.
  • پلی که state را روی packet line متصل می‌کند، آن state را در طول پردازش تعداد زیادی packet همان worker نگه می‌دارد.
  • اگر کد شما به Init مربوط به packet line وابسته است، منشأ این Init را بررسی کنید.

مهم‌ترین اشتباهی که باید از آن دوری کنید:

استفاده از Packet Lineها به‌گونه‌ای که گویی Lineهای عادی اتصال با چرخهٔ عمر معمول
Init -> Payload -> Finish برای هر اتصال‌اند. چنین نیستند.

ادامه: بخش ۵: ساختار یک تونل و روند کار.