تصویر مقاله
عکس از Marek Okon در Unsplash

پروتکل Unix Domain در حقیقت بیش از آنکه یک پروتکل شبکه به معنای مرسوم باشد، روشی استاندارد برای برقراری ارتباط در یک سیستم مستقل (Single Host) با استفاده از همان APIهای آشنای Client / Server است.

این سازوکار را می‌توان یکی از راهکارهای کارآمد IPC (ارتباط بین‌پردازشی) دانست؛ به بیان دیگر، مکانیزمی قدرتمند برای تبادل داده میان پردازه‌های مختلف در یک کامپیوتر واحد به شمار می‌رود.

پیاده‌سازی این پروتکل‌ها با نام Unix Domain Socket شناخته می‌شود که درست مانند پروتکل‌های شبکه، به دو دستهٔ اصلی متناظر با مفاهیم TCP و UDP تقسیم می‌شوند:

  • stream sockets ~= TCP
  • datagram sockets ~= UDP

همان‌طور که در TCP و UDP از آدرس IP و Port برای شناسایی کلاینت و سرور استفاده می‌کنیم، در پروتکل‌های Unix Domain از یک مسیر در سیستم‌فایل (File System Path) به عنوان آدرس پروتکل بهره گرفته می‌شود.

  • البته این فایل‌ها معمولی نیستند و تنها برنامه‌های مرتبط و مجاز قادر به دسترسی به آن‌ها خواهند بود.

فهرست مطالب

  1. چرا از پروتکل Unix Domain Socket استفاده می‌کنیم؟
  2. ساختار آدرس در Unix Domain Socket
  3. تفاوت‌های کلیدی در توابع Unix Domain Socket
  4. پیاده‌سازی Client / Server با Unix Domain Socket
  5. انتقال توصیف‌کننده‌ها (Passing Descriptors)
  6. دریافت اطلاعات احراز هویت فرستنده (Receiving Sender Credentials)

۱. چرا از پروتکل Unix Domain Socket استفاده می‌کنیم؟

  1. ۱. در پیاده‌سازی‌های Berkeley، استفاده از Unix Domain Socket در یک هاست واحد تا دو برابر سریع‌تر است. برای مثال در X Window System، اگر سرور و کلاینت روی یک سیستم مشترک باشند، ارتباط به جای اتصال TCP، از طریق Unix Domain Stream برقرار می‌شود.

۲. این سوکت‌ها امکان انتقال توصیف‌کننده‌ها (Descriptors) میان پردازه‌های مختلف را فراهم می‌کنند؛ قابلیتی که کاملاً هم‌راستا با کاربرد آن به عنوان راهکاری برای IPC است.

۳. پیاده‌سازی‌های جدیدتر Unix Domain Socket امکان ارسال اطلاعات احراز هویت کلاینت (Credentials) را فراهم کرده‌اند که فرآیند اعتبارسنجی و بررسی‌های امنیتی را بسیار آسان‌تر و دقیق‌تر می‌کند.

۲. ساختار آدرس در Unix Domain Socket

این ساختار که به اختصار UDS نیز خوانده می‌شود، دارای تعریف و مشخصات زیر است:

C++
  • استاندارد POSIX طول معینی را برای آرایهٔ sun_path تعریف نمی‌کند و صراحتاً هشدار می‌دهد که نباید هیچ طول مشخصی را برای آن پیش‌فرض گرفت.
  • در عمل، اندازهٔ sun_path معمولاً عددی بین ۹۲ تا ۱۰۸ بایت است؛ این محدوده به این دلیل شکل گرفته که داده‌ها باید درون بافر حافظهٔ Kernel به اندازهٔ ۱۲۴ بایت جا می‌گرفتند.
  • مسیری که در آرایهٔ sun_path ذخیره می‌شود، حتماً باید با کاراکتر null خاتمه یابد.
اگرچه در طول این فصل پیوسته از عبارت «Unix Domain Socket» استفاده می‌کنیم، اما استاندارد POSIX نام این پروتکل‌ها را رسماً به «Local IPC» تغییر داده است. به همین علت در ساختار sa_family هر دو مقدار AF_LOCAL و AF_UNIX به چشم می‌خورند. با این وجود، صرف‌نظر از نوع سیستم‌عامل، همچنان همه‌جا آن را با نام «Unix Domain» می‌شناسند و در نام اعضای ساختار نیز پیشوند un_ حفظ شده است.

مثال) استفاده از تابع bind در سوکت‌های Unix Domain

در این بخش، پس از ایجاد یک Unix Domain Socket و فراخوانی تابع bind، اطلاعات مربوط به سوکت را چاپ می‌کنیم:

C++

با ارسال مسیر به عنوان آرگومان به تابع main و اجرای آن، سوکت یونیکسی که به همان مسیر متصل (Bind) شده است در اختیارتان قرار می‌گیرد.

نکتهٔ حائز اهمیت این است که طبق استاندارد POSIX، سطح دسترسی فایل سوکتِ ایجادشده بر اساس مقدار umask سیستم تعیین می‌گردد.

تصویر مقاله

اگر با دستور ls -l دسترسی‌ها را بررسی کنید، مشاهده خواهید کرد که مجوزهای فایل دقیقاً مطابق با تنظیمات umask شکل گرفته‌اند.

طول ساختار بازگردانده‌شده حاصل جمع Address Family (۲ بایت) به علاوهٔ طول مسیر (۱۱ بایت) است؛ این فرآیند نیز یکی از نمونه‌های کاربردی آرگومان‌های Value-Result به شمار می‌رود.

۳. تفاوت‌های کلیدی در توابع Unix Domain Socket

توابع سوکت در UDS تفاوت‌های مشخصی با سوکت‌های معمول شبکه دارند که از الزامات POSIX پیروی می‌کنند (اگرچه ممکن است همهٔ پیاده‌سازی‌ها به‌طور کامل از این موارد تبعیت نکنند):

۱. دسترسی پیش‌فرض مسیری که توسط bind ایجاد می‌شود باید 0777 بوده و از مقدار فعلی umask پیروی کند؛ این یعنی کنترل مجوزها باید منحصراً از طریق umask انجام پذیرد (محدودیتی که برای حفظ امنیت و قابلیت جابه‌جایی اعمال شده است). ۲. مسیر فایل حتماً باید یک مسیر مطلق (Absolute Path) باشد.

۳. مسیری که هنگام فراخوانی connect تعیین می‌شود، باید مربوط به یک سوکت Unix Domain باز و هم‌نوع (Stream یا Datagram) باشد. موارد زیر از خطاهای نقض این شرط هستند: - ۱-۳. مسیر وجود دارد اما فایل موردنظر از نوع سوکت نیست. - ۲-۳. فایل سوکت در سیستم‌فایل موجود است اما هیچ توصیف‌کنندهٔ باز (Socket Descriptor) برای آن وجود ندارد. - ۳-۳. شرایط قبلی برقرار است اما نوع سوکت‌ها یکسان نیست.

۴. بررسی دسترسی‌ها برای تابع connect دقیقاً معادل با فراخوانی تابع open در حالت Write-Only است.

۵. سوکت‌های Unix Domain Stream عملکردی شبیه به سوکت‌های TCP دارند و یک رابط جریان بایت (Byte Stream Interface) در اختیار پردازه قرار می‌دهند.

۶. هنگام فراخوانی connect روی یک سوکت Unix Domain Stream، اگر صفِ Listening Socket پر باشد، برعکسِ TCP که برای فراهم کردن امکان تلاش مجدد درخواست را نادیده می‌گیرد، بلافاصله خطای ECONNREFUSED برمی‌گردد؛ چرا که سیستم‌عامل وضعیت سوکت محلی را فوراً تشخیص می‌دهد.

۷. سوکت‌های Unix Domain Datagram شبیه به UDP عمل می‌کنند و دیتاگرام‌های غیرقابل‌اطمینان (Unreliable) را با حفظ مرز رکوردها (Record Boundary) ارسال می‌نمایند.

۸. بر خلاف سوکت‌های UDP، اگر داده‌ای را به یک Unix Domain Datagram Socket بدون Bind ارسال کنید، سیستم‌عامل به‌طور خودکار آن را به یک مسیر نگاشت نمی‌کند؛ بنابراین اگر پیش از دریافت داده عملیات bind را انجام ندهید، قادر به دریافت اطلاعات نخواهید بود. به همین ترتیب، فراخوانی connect نیز عمل نگاشت مسیر را انجام نمی‌دهد.

۴. پیاده‌سازی Client / Server با Unix Domain Socket

در این بخش، معماری Client / Server مبتنی بر TCP را این بار با استفاده از سوکت‌های Unix Domain Stream (مشابه TCP) و سوکت‌های Unix Domain Datagram (مشابه UDP) پیاده‌سازی می‌کنیم.

کلاینت و سرور Unix Domain Stream Socket

سرور (Server)

C++

کلاینت (Client)

C++

کلاینت و سرور Unix Domain Stream Socket

سرور (Server)

OBJECTIVEC

کلاینت (Client)

C++
  • بر خلاف UDP، در اینجا باید مسیر (آدرس) را مشخص کرده و تابع bind را فراخوانی کنید.

۵. انتقال دیسکریپتورها (Passing Descriptors)

اگر بخواهید یک descriptor باز را از یک پردازه به پردازه دیگری منتقل کنید، احتمالاً یکی از دو روش زیر به ذهنتان می‌رسد:

  1. ۱. پردازه والد (Parent) تابع fork را فراخوانی کند تا تمام descriptorهای باز به اشتراک گذاشته شوند.
  • در این روش، پس از اجرای fork، پردازه والد دسترسی خود به descriptor را رها می‌کند؛ با این کار می‌توان دیسکریپتور را از والد به فرزند منتقل کرد، اما مسئله این است که گاهی نیاز داریم دیسکریپتور از پردازه فرزند به پردازه والد ارسال شود.

۲. به طور معمول با فراخوانی exec، تمام descriptorها باز باقی خواهند ماند.

در یونیکس می‌توان با استفاده از Unix Domain Socket، دیسکریپتورها را حتی میان پردازه‌های کاملاً نامرتبط (غیر از والد و فرزند) نیز رد و بدل کرد.

برای این کار، یک Unix Domain Socket بین دو پردازه ایجاد شده و با تابع `sendmsg` یک پیام ویژه ارسال می‌شود؛ این پیام به صورت خاص توسط هسته (Kernel) پردازش شده و descriptor باز را از فرستنده به گیرنده تحویل می‌دهد.

  • گاهی از فراخوانی ioctl با رویدادهای I_SENDFD و I_RECVFD نیز استفاده می‌شود، اما این روش تنها در پیاده‌سازی‌های خاصی مانند SVR4 قابل اجراست.
  • استفاده از Unix Domain Socket بالاترین میزان سازگاری و قابلیت حمل (Portability) را دارد و در این کتاب نیز تمرکز اصلی روی همین روش است.

مراحل انتقال دیسکریپتور (Descriptor Passing)

مراحل انتقال دیسکریپتور به صورت زیر انجام می‌شود:

  1. ۱. ساخت Unix Domain Socket
  • اگر هدف انتقال میان پردازه‌های والد و فرزند باشد، می‌توان با استفاده از socketpair یک لوله ارتباطی (Pipe) ایجاد کرد.
  • اما اگر پردازه‌ها ارتباطی با یکدیگر نداشته باشند، باید حتماً یک Unix Domain Socket ساخته شده و به یک مسیر مشخص (Pathname) متصل (Bind) شود.
  • نوع سوکت تفاوتی ندارد، اما معمولاً از نوع Stream استفاده می‌شود؛ سوکت‌های Datagram نیز قابل استفاده‌اند، اما مزیت خاصی نداشته و احتمال از دست رفتن اطلاعات نیز وجود دارد.

۲. یکی از پردازه‌ها تابعی از یونیکس را که خروجی آن یک descriptor است فراخوانی می‌کند (مانند open، pipe و غیره).

۳. پردازه ارسال‌کننده با ایجاد descriptor مدنظر، ساختار msghdr را تشکیل می‌دهد.

  • در استاندارد POSIX، این descriptor در قالب داده‌های کمکی (ancillary data) ارسال می‌شود.

۴. فرستنده تابع sendmsg را برای ارسال دیسکریپتور فراخوانی می‌کند.

  • در این حالت اصطلاحاً گفته می‌شود که دیسکریپتور در وضعیت «در حال انتقال» (in-flight) قرار دارد.
  • حتی اگر پردازه فرستنده دیسکریپتور را ببندد، تا زمانی که گیرنده تابع recvmsg را فراخوانی نکند، این دیسکریپتور برای پردازه گیرنده باز نگه داشته می‌شود.

۵. در سمت گیرنده، تابع recvmsg فراخوانی می‌شود.

  • در این مرحله ممکن است شماره دیسکریپتور متفاوت باشد؛ چرا که در عمل خود شماره دیسکریپتور ارسال نمی‌شود، بلکه هسته سیستم‌عامل دیسکریپتور جدیدی با همان ورودی فایل مشترک (File Entry) برای گیرنده ایجاد می‌کند.

پیش از ادامه، لازم است نگاهی به تابع socketpair بیندازیم؛ هرچند در کتاب کمی قبل‌تر به آن اشاره شد، اما به دلیل کاربرد مستقیم در این بخش، ترتیب مباحث را تغییر داده‌ایم.

تابع socketpair

این تابع (منحصراً برای Unix Domain Socket) دو سوکت متصل‌به‌هم ایجاد می‌کند.

C++

مقدار family باید برابر با AF_LOCAL و protocol برابر با 0 باشد؛ البته نوع سوکت (Type) می‌تواند SOCK_STREAM یا SOCK_DGRAM تعیین شود.

آرایه sockfd شامل دو دیسکریپتور سوکت ایجادشده خواهد بود.

دو سوکت متصل‌به‌هم که با نوع SOCK_STREAM ساخته می‌شوند، اصطلاحاً stream pipe نامیده می‌شوند.

مثال: پیاده‌سازی برنامه mycat

در این قسمت برنامه‌ای به نام mycat را پیاده‌سازی می‌کنیم.

عملکرد mycat به این صورت است که باز کردن فایل مشخص‌شده در آرگومان خط فرمان را به برنامه‌ای دیگر واگذار کرده، دیسکریپتور آن را پس می‌گیرد و محتوا را به خروجی استاندارد ارسال می‌کند.

  1. ۱. با فراخوانی تابع socketpair، یک stream pipe ساخته می‌شود.
تصویر مقاله

۲. پردازه والد با fork یک پردازه فرزند می‌سازد و فرزند با اجرای exec برنامه openfile را اجرا می‌کند؛ در همین حین، والد و فرزند هر کدام دیسکریپتورهای اضافی و بلااستفاده خود را می‌بندند.

هنگام اجرای برنامه openfile، پردازه والد باید اطلاعات زیر را از طریق آرگومان‌های خط فرمان در exec یا از طریق stream pipe ارسال کند:

  • - نام مسیر (Pathname)
  • - حالت دسترسی یا Mode (مانند read-only، read-write و غیره)
  • - شماره دیسکریپتور مربوط به انتهای stream pipe (در دیاگرام زیر، sockfd[1])
تصویر مقاله

۳. برنامه openfile فایل را باز کرده، دیسکریپتور بازشده را از طریق stream pipe به پردازه والد ارسال می‌کند و سپس خارج می‌شود؛ پردازه والد از طریق وضعیت خروج (exit status) فرزند متوجه می‌شود که آیا فایل با موفقیت باز شده یا خطایی رخ داده است.

پیاده‌سازی این مراحل در قالب کد به شکل زیر خواهد بود:

کپی کردن فایل

C++

اگر تابع my_open را معادل همان open در نظر بگیرید، ساختاری شبیه به یک تابع کپی ساده و معمولی دارد.

حال بیایید جزئیات تابع my_open را که وظیفه انتقال دیسکریپتور بین پردازه‌ها را بر عهده دارد بررسی کنیم.

C++

در بخش بالا، هنگامی که پردازه فرزند با موفقیت اجرا شده و دیسکریپتور فایل را بازمی‌گرداند، دستور `Read_fd(sockfd[0], &c, 1, &fd);` فراخوانی می‌شود.

ساختار داخلی آن به صورت زیر است:

OBJECTIVEC

مقدار دیسکریپتور فایلی که به این شیوه دریافت شده، به عنوان مقدار بازگشتی تابع my_open عمل کرده و امکان دسترسی به دیسکریپتور فایل را در تابع main فراهم می‌سازد.

پردازه فرزند مراحل انتقال دیسکریپتور فایل را به این ترتیب انجام می‌دهد:

این تابع اصلی (main) در برنامه openfile است؛ هدف اصلی در اینجا صرفاً بررسی محل فراخوانی write_fd است.

C++

این تابع write_fd است که دیسکریپتور سوکت را گرفته و داده‌های دیسکریپتور فایل را منتقل می‌کند؛ ساختار آن شباهت زیادی به read_fd دارد.

OBJECTIVEC

مشابه تابع read_fd، در اینجا نیز علاوه بر فرمت cmsghdr باید از سایر فرمت‌های موجود نیز پشتیبانی شود.

۶. دریافت اطلاعات هویتی فرستنده (Receiving Sender Credentials)

نحوه قرار دادن اطلاعات احراز هویت در ancillary data در سیستم‌عامل‌های مختلف فرق می‌کند، اما توضیحات این کتاب بر مبنای FreeBSD ارائه شده است.

در FreeBSD، ساختار cmsgcred برای انتقال اطلاعات هویتی استفاده می‌شود که ساختار آن به شرح زیر است:

C++

اگرچه اطلاعات credential همیشه در دسترس هستند، اما شرایط خاصی دارند؛ از جمله مشخص کردن داده‌های الزامی هنگام ارسال یا تنظیم سوکت آپشن‌های (socket option) مورد نیاز در سمت گیرنده.

در FreeBSD، گیرنده کار خاصی جز در نظر گرفتن یک بافر با اندازه کافی برای ancillary data (جهت جا دادن تمام اطلاعات هویتی) ندارد، اما فرستنده باید هنگام ارسال پیام با sendmsg، ساختار cmsgcred را همیشه به همراه آن بفرستد.

با اینکه فرستنده ساختار cmsgcred را ارسال می‌کند، اما مقادیر آن مستقیماً توسط هسته (Kernel) پر می‌شود و در نتیجه امکان جعل اطلاعات وجود ندارد.

مثال: تابع str_echo برای دریافت اطلاعات هویتی کلاینت در سرور Unix Domain Stream

در سمت سرور، تابعی برای خواندن اطلاعات credential به شکل زیر اضافه می‌کنیم:

C++

در تابع str_echo، با استفاده از read_cred اطلاعات هویتی نیز همراه با ورودی دریافت و خوانده می‌شوند.

C++

خلاصه فصل

  • سوکت‌های یونیکس دامین (Unix Domain Sockets) به عنوان راهکاری در IPC برای انتقال داده بین پردازه‌ها استفاده می‌شوند؛ این سوکت‌ها اینترفیسی مشابه با سوکت‌های شبکه دارند و در عین حال کارایی و سرعت بالاتری ارائه می‌دهند.
  • هنگام برقراری ارتباط با سوکت‌های دیتگرام یونیکس دامین (که مفهومی شبیه به UDP دارند)، بر خلاف کلاینت‌های معمولی UDP، فراخوانی تابع bind کاملاً ضروری است.
  • در نهایت، مفهوم Descriptor Passing را به عنوان یکی از سازوکارهای کلیدی در تبادل داده میان پردازه‌ها مرور کردیم.