الأدلة التقنية

مكالمات WebRTC خلف NAT: شرح STUN وTURN وICE

لماذا تتصل المكالمة ولا يصل الصوت، وكيف تحل STUN وTURN المشكلة دون أن تفتح باباً إلى شبكتك الداخلية.

مكالمات WebRTC خلف NAT: شرح STUN وTURN وICE

تبدأ المكالمة في Mattermost أو في أي تطبيق يعمل بال WebRTC، ويظهر اسم زميلك في نافذة المكالمة، ثم لا تسمع صوته ولا يسمع صوتك، وبعد ثوان تنقطع المكالمة. وفي اليوم التالي يجربها من شبكة البيت فتعمل، ثم يعود إلى المكتب فتفشل مرة أخرى. والسبب في أغلب الحالات أن الصوت والصورة في WebRTC يحاولان أن يذهبا مباشرة من جهاز إلى جهاز، وهذا الطريق المباشر يمر عبر NAT وجدران الحماية Firewalls، فينجح في شبكة ويفشل في أخرى، والحل المعتمد عندما يفشل هو سيرفر TURN يمرر الحركة بين الطرفين.

وقد شرحنا في شرح NAT للمبتدئين أنواع NAT وCGNAT وتوجيه المنافذ، ومررنا هناك على STUN وHole Punching وTURN مروراً سريعاً، وفي دليل Mattermost فتحنا المنفذ 8443 للمكالمات وذكرنا أن من لا يستطيع فتحه يحتاج سيرفر TURN. وهذا المقال يكمل من هناك، فلن نعيد شرح أنواع NAT، وإنما سوف نتابع المكالمة نفسها من لحظة بدئها إلى أن يصل الصوت، وسوف نناقش في هذا المقال ما يلي:

  • لماذا ينقطع الطريق المباشر بين جهازين، والفرق بين قناة التنسيق Signaling وطريق الوسائط Media، وعرض وجواب SDP في فقرة واحدة.
  • كيف يجمع ICE العناوين المرشحة Candidates بأنواعها host وsrflx وrelay، وكيف يختبرها ليختار الطريق.
  • STUN كما في RFC 8489، وTURN كما في RFC 8656: ال Allocation والأذونات والقنوات، والمنافذ، والبديل عبر TCP وTLS على المنفذ 443، وكم يكلفك ال Relay من Bandwidth.
  • متى تحتاج TURN فعلاً، وما الأرقام المنشورة عن نجاح الاتصال المباشر وحدودها.
  • بيانات الدخول الثابتة والمؤقتة، وطريقة السر المشترك Shared Secret في coturn.
  • كيف يصبح سيرفر TURN باباً إلى شبكتك الداخلية، وكيف تغلقه، ثم Mesh وSFU وMCU باختصار.
  • تجربة عملية مع coturn 4.18.0 وعميل WebRTC بلغة Python لا يتصل إلا عبر ال Relay، نرى فيها العناوين المرشحة والسجلات والخطأ 401 والخطأ 403.

لماذا تتصل المكالمة ولا يصل الصوت؟

صفحة الويب العادية لا تتأثر بال NAT، لأن المتصفح يبدأ الاتصال دائماً والسيرفر له عنوان عام، أما في المكالمة فالمطلوب أن يرسل كل جهاز الصوت إلى الجهاز الآخر مباشرة، ويكون الجهازان في الغالب خلف NAT، فلا يملك أي منهما عنواناً عاماً يستقبل عليه. وقد رأينا في شرح NAT أن الطرفين يستطيعان فتح طريق مباشر بالطريقة المسماة Hole Punching إذا كان كل NAT يعطي الجهاز المنفذ العام نفسه مهما تغيرت الوجهة، وهذا هو السلوك الذي يسميه RFC 4787 بال Endpoint-Independent Mapping، أما إذا كان أحد الطرفين خلف NAT من النوع Symmetric، أو خلف CGNAT عند المزود يتصرف بهذه الطريقة، فالمنفذ الذي عرفه الطرف عن نفسه ليس هو المنفذ الذي سوف يفتح للطرف الآخر، فتضيع الحزم.

وهناك حالة ثالثة لا علاقة لها بنوع NAT، وهي شبكات الشركات التي يمنع جدار الحماية فيها كل حركة UDP خارجة، ولا يسمح إلا بالاتصال عبر TCP على المنفذ 443، وأحياناً عبر Proxy فقط، وهنا لا يصل أي طريق مباشر مهما كان نوع NAT. والصورة التالية تبين الحالتين، ولاحظ في أسفلها أن قناة التنسيق تعمل في الحالتين، لأنها تمر عبر سيرفر التطبيق مثل أي صفحة ويب، وأن الذي يفشل هو طريق الصوت والصورة فقط، وهذا ما يفسر أن المكالمة «تتصل» ثم لا يصل فيها شيء:

مخطط من جزأين: في الأعلى جهازان كل منهما خلف NAT يحافظ على المنفذ نفسه فيمر الصوت مباشرة بينهما عبر UDP، وفي الأسفل جهاز يحاول الوصول إلى هاتف خلف CGNAT أو NAT من نوع Symmetric فيفشل لأن المنفذ يتغير، وإلى حاسوب خلف جدار حماية شركة يمنع UDP ولا يسمح إلا بالمنفذ 443 فتسقط الحزم
الطريق المباشر ينجح بين أغلب الشبكات المنزلية، ويفشل خلف بعض أنواع NAT وخلف جدران الحماية التي تمنع UDP

وقد يتساءل البعض: لماذا لا يمر الصوت عبر سيرفر التطبيق من البداية ونرتاح من كل هذا؟ والإجابة أن هذا ممكن وهو ما تفعله المكالمات الجماعية كما سوف نرى في قسم Mesh وSFU وMCU، ولكن في المكالمة الثنائية يكون الطريق المباشر أقصر في الغالب، فيقل التأخير Latency، ولا يدفع صاحب الخدمة ثمن ال Bandwidth، لذلك صمم WebRTC ليحاول الطريق المباشر أولاً، ولا يستخدم سيرفراً وسيطاً إلا عند الحاجة.

قناة التنسيق Signaling وطريق الوسائط Media

في كل مكالمة WebRTC طريقان منفصلان، والخلط بينهما هو أكثر ما يربك من يشخص مشكلة صوت. الطريق الأول هو قناة التنسيق Signaling، وهي رسائل نصية يتبادلها الطرفان عبر سيرفر التطبيق ليتفقا على المكالمة: من يتصل بمن، وما الصيغ التي يدعمها كل منهما، وما عناوينه المحتملة. ولاحظ أن WebRTC لا يحدد كيف تنقل هذه الرسائل، فقد ذكر RFC 9429 الذي يعرف واجهة JSEP أن آلية التنسيق متروكة للتطبيق، فيستخدم Mattermost اتصال WebSocket على المنفذ 443، ويستخدم غيره طلبات HTTP عادية. أما الطريق الثاني فهو طريق الوسائط Media، وفيه يذهب الصوت والصورة والبيانات من جهاز إلى جهاز عبر UDP في الغالب، مشفرة دائماً بال DTLS-SRTP الذي يفرضه RFC 8827، والصورة التالية تبين الطريقين:

سيرفر التطبيق في الأعلى يمرر عرض SDP مع العناوين المرشحة من الطرف A إلى الطرف B عبر HTTPS أو WebSocket على المنفذ 443، ثم يمرر الجواب في الاتجاه المعاكس، وفي الأسفل يختبر الطرفان أزواج العناوين مباشرة بطلبات STUN Binding ثم يمر الصوت والصورة بينهما عبر UDP مشفراً بال DTLS-SRTP
قناة التنسيق تمر عبر سيرفرك، والصوت يأخذ الطريق الذي ينجح فيه ICE

عرض وجواب SDP في فقرة واحدة

الرسائل التي تمر في قناة التنسيق مكتوبة بصيغة وصف الجلسة Session Description Protocol، واختصاراً SDP، التي يعرفها RFC 8866، وتتبع نموذج العرض والجواب Offer/Answer الذي يعرفه RFC 3264: يكتب المتصل عرضاً Offer فيه قسم لكل نوع من الوسائط، صوت أو فيديو أو قناة بيانات، ومع كل قسم الصيغ Codecs التي يدعمها وبصمة شهادة DTLS واسم مستخدم وكلمة مرور قصيرة لفحوص ICE، ثم أسطر تبدأ ب a=candidate: فيها عناوينه المحتملة كما يعرفها RFC 8839، فيرد الطرف الآخر بجواب Answer يختار فيه من هذه الصيغ ويضيف عناوينه. وهذه الأسطر هي التي سوف نقرؤها في التجربة، وهي أهم ما في ال SDP لمن يشخص مشكلة اتصال.

ICE: كيف يختار الطرفان الطريق؟

الإجراء الذي يجمع العناوين ويختبرها ويختار منها اسمه Interactive Connectivity Establishment، واختصاراً ICE، ويعرفه RFC 8445، وRFC 8835 يلزم كل متصفح يدعم WebRTC بتطبيقه كاملاً، وبدعم TURN أيضاً للحالات التي يكون فيها الطرفان خلف NAT يغير المنفذ مع كل وجهة.

أنواع العناوين المرشحة: host وsrflx وrelay

يجمع كل طرف قبل المكالمة ثلاثة أنواع من العناوين المرشحة Candidates، والصورة التالية تبين من أين يأتي كل نوع:

حاسوب في شبكة منزلية عنوانه 192.168.1.20 ومنفذه 50000 وهو العنوان من نوع host، وال NAT يترجمه إلى 203.0.113.10 على المنفذ 62000 ويعرف الحاسوب هذا العنوان من سيرفر STUN وهو من نوع srflx، وسيرفر TURN يعطيه عنواناً على السيرفر نفسه 198.51.100.5 على المنفذ 49170 وهو من نوع relay، وفي الأسفل جدول بالأنواع الثلاثة وأولوياتها 126 و100 و0
ثلاثة عناوين للجهاز نفسه، على الجهاز وعلى ال NAT وعلى سيرفر TURN
  • العنوان المحلي host: عنوان الجهاز نفسه على شبكته، مثل 192.168.1.20:50000، وهو يكفي إذا كان الطرفان على الشبكة نفسها.
  • العنوان العام كما يراه السيرفر Server Reflexive، واختصاراً srflx: العنوان العام الذي يظهر به الجهاز بعد ترجمة NAT، ويعرفه الجهاز عندما يسأل سيرفر STUN، وهو العنوان الذي يستخدمه Hole Punching.
  • عنوان ال Relay: عنوان على سيرفر TURN نفسه حجزه الجهاز لنفسه، وكل ما يصل إلى هذا العنوان يمرره السيرفر إلى الجهاز، وهو آخر الحلول ولكنه الذي ينجح حتى خلف أصعب الشبكات.

ويعطي ICE كل نوع أولوية، والقسم 5.1.2.2 من المعيار يوصي بالقيمة 126 للعنوان المحلي و100 للعنوان العام و0 لعنوان ال Relay، والسبب واضح: الطريق المحلي أقصر وأرخص، وال Relay أطول طريق وأغلاه. وهناك نوع رابع اسمه Peer Reflexive، واختصاراً prflx، أولويته 110، ولا يجمعه الجهاز بنفسه وإنما يكتشفه أثناء الفحوص عندما يصله طلب من عنوان لم يكن في القائمة.

فحوص الاتصال Connectivity Checks واختيار الزوج

بعد أن يتبادل الطرفان القوائم، يكون كل عنوان محلي مع كل عنوان عند الطرف الآخر زوجاً مرشحاً Candidate Pair، فيرتب ICE الأزواج بالأولوية، ثم يرسل على كل زوج طلب STUN Binding صغيراً موقعاً باسم المستخدم وكلمة المرور القصيرة اللذين وصلا في ال SDP، فإذا وصل الرد أصبح الزوج صالحاً، والطرف الذي يقود العملية Controlling Agent يرشح Nominate زوجاً من الأزواج الصالحة فيصبح هو الطريق الذي يمر عليه الصوت. ولاحظ أن هذه الفحوص نفسها هي التي تفتح الطريق في ال NAT، فالطلب الخارج من كل طرف يفتح له صفاً في جدول الترجمة، وهي فكرة Hole Punching نفسها ولكن بطريقة منظمة تجرب كل الاحتمالات. وواجهة WebRTC في المتصفح لا تجبرك على انتظار القائمة كاملة، فهي تعطيك كل عنوان فور اكتشافه في الحدث icecandidate لترسله مباشرة، وهذه الطريقة اسمها Trickle ICE ويعرفها RFC 8838، فتبدأ المكالمة أسرع.

STUN: سيرفر يخبرك بعنوانك ولا يحمل صوتاً

Session Traversal Utilities for NAT، واختصاراً STUN، هو أبسط الأجزاء: يرسل الجهاز طلب Binding إلى سيرفر STUN على المنفذ 3478 بال UDP أو TCP، أو على المنفذ 5349 عبر TLS، فيرد السيرفر بالخاصية XOR-MAPPED-ADDRESS وفيها العنوان والمنفذ اللذان رأى الطلب قادماً منهما، وهذا هو العنوان من نوع srflx. ولأن السيرفر يرد على رسالة صغيرة ثم ينسى الطلب، فإن تشغيله لا يكلف شيئاً يذكر، ولا يمر عبره أي صوت، ونفس البروتوكول هو الذي تستخدمه فحوص ICE بين الطرفين كما رأينا أعلاه. والمعلومة المهمة هنا أن STUN يخبرك بعنوانك فقط، ولا يضمن أن الطرف الآخر يستطيع الوصول إليه، فإذا كان NAT من النوع Symmetric فالعنوان الذي يعيده STUN صحيح بالنسبة لسيرفر STUN وحده.

TURN: عندما يجب أن يمر الصوت عبر سيرفر وسيط

Traversal Using Relays around NAT، واختصاراً TURN، يحل ما لا يحله STUN: بدلاً من أن يعرف الجهاز عنوانه العام، يحجز عنواناً عاماً على سيرفر وسيط، فيرسل كل شيء إلى السيرفر، ويرسله السيرفر باسمه إلى الطرف الآخر، والصورة التالية تبين الخطوات:

حاسوب في مكتب خلف جدار حماية لا يسمح إلا بالمنفذ 443 يتصل بسيرفر coturn عبر TLS، والسيرفر يستمع على المنافذ 3478 و5349 و443، ويحجز له عنوان Relay هو 198.51.100.5 على المنفذ 49170، ويرسل منه الحركة عبر UDP إلى هاتف خلف CGNAT، ويعيد ردوده إلى الحاسوب، مع أربع خطوات مرقمة وملاحظة أن كل حزمة تدخل السيرفر وتخرج منه
الطرفان يتحدثان مع عنوان ال Relay، والسيرفر يمرر بينهما كل حزمة

ال Allocation والأذونات Permissions والقنوات Channels

  1. ال Allocation: يرسل الجهاز طلب Allocate إلى السيرفر، فيرد السيرفر أولاً بالخطأ 401 ومعه Realm وقيمة Nonce، وهذا الرد طبيعي وجزء من المصادقة Authentication، ثم يعيد الجهاز الطلب موقعاً باسم المستخدم وكلمة المرور، فيحجز له السيرفر عنواناً ومنفذاً خاصاً به اسمه Relayed Transport Address. والمعيار يجعل عمر ال Allocation الافتراضي 10 دقائق، ويجدده الجهاز بطلب Refresh ما دامت المكالمة قائمة، ويوصي بأن يأخذ السيرفر المنافذ من النطاق 49152 إلى 65535.
  2. الأذونات Permissions: لا يقبل السيرفر على عنوان ال Relay أي حزمة من أي أحد، وإنما من العناوين التي أذن لها الجهاز بطلب CreatePermission، والإذن عمره 5 دقائق بالضبط ويجدد، وبهذا يتصرف السيرفر مثل NAT يطبق التصفية بحسب العنوان Address-Dependent Filtering، فلا يستطيع غريب أن يرسل إلى الجهاز عبر ال Relay ما لم يراسله الجهاز أولاً، كما يشرح قسم الأمان في المعيار.
  3. إرسال البيانات: يرسل الجهاز كل حزمة داخل رسالة Send، ويضيف السيرفر لكل حزمة واردة رسالة Data، وفي كل منهما ترويسة STUN كاملة، لذلك يوفر المعيار القنوات Channels، حيث يربط الجهاز رقم قناة من 0x4000 إلى 0x4FFF بعنوان الطرف الآخر بطلب ChannelBind، فتصبح الترويسة أربعة بايتات فقط، والقناة عمرها 10 دقائق وتجدد.

ولاحظ أن الطرف الآخر لا يحتاج أن يعرف أن هناك TURN، فهو يرى عنوان ال Relay كأي عنوان مرشح آخر من نوع relay، ويرسل إليه حزمه العادية، ولهذا يكفي في كثير من الحالات أن يستخدم طرف واحد فقط ال Relay، كما سوف ترى في التجربة.

UDP ثم TCP ثم TLS على المنفذ 443

الاتصال بين الجهاز وسيرفر TURN يمكن أن يكون عبر UDP، وهو الأفضل للصوت لأنه لا يعيد إرسال ما ضاع، أو عبر TCP، أو عبر TLS فوق TCP، وعنوان السيرفر يكتب في إعداد التطبيق بصيغة turn: أو turns: التي يعرفها RFC 7065، مع ?transport=udp أو ?transport=tcp. وRFC 8835 يلزم متصفحات WebRTC بدعم TURN عبر TCP وعبر TLS، والسبب الذي يذكره صراحة هو جدران الحماية التي تمنع كل حركة UDP. ولذلك يشغل كثير من المشغلين TURN عبر TLS على المنفذ 443، لأن جدار الحماية في الشركة يرى عندها اتصال TLS عادياً على منفذ HTTPS فيسمح به، وRFC 8828 الذي يحدد ما يكشفه المتصفح من عناوين يوصي التطبيقات صراحة بأن يدعم سيرفر TURN عندها UDP وTCP معاً، لأن بعض أوضاع الخصوصية في المتصفح لا تسمح إلا بالمرور عبر Proxy أو عبر TCP. ولاحظ أن الصوت فوق TCP يتأثر بفقد الحزم أكثر، لأن TCP يوقف كل ما بعد الحزمة الضائعة حتى تصل، فهذا الطريق للنجاة من جدار الحماية وليس للجودة.

ويجدر الانتباه إلى أن الطرف الثاني من ال Relay، أي بين سيرفر TURN والطرف الآخر، يبقى UDP في WebRTC حتى عندما يتصل الجهاز بالسيرفر عبر TCP، أما ال Relay الذي يكون فيه هذا الطرف أيضاً TCP فيعرفه RFC 6062، وRFC 8835 يجعل دعمه اختيارياً ويذكر أنه لا يضيف فائدة تذكر.

كم يكلفك ال Relay من Bandwidth؟

هنا الفرق الكبير بين STUN وTURN. فسيرفر STUN يرد على رسالة صغيرة في بداية المكالمة، أما سيرفر TURN فكل حزمة صوت وصورة تدخله مرة وتخرج منه مرة، فإذا كانت المكالمة تستهلك X ميجابت في الثانية في كل اتجاه، فإن السيرفر يستقبل 2X ويرسل 2X طوال المكالمة، وإذا استخدم الطرفان معاً Relay على السيرفر نفسه، كما في التجربة، مرت الحزمة عبره مرتين فتضاعف الرقم. لذلك اسأل نفسك قبل تشغيل TURN: كم مكالمة متزامنة تتوقع أن تمر عبره، وما سعر ال Bandwidth الخارج عند مزود السيرفر؟ وقد شرحنا في التكاليف الخفية في السحابة لماذا تكون الحركة الخارجة Egress من أكبر بنود الفاتورة. وفي المقابل فإن المنافذ المحجوزة تحدد عدد الجلسات المتزامنة، لأن كل Allocation يأخذ منفذاً من النطاق، فالنطاق من 49160 إلى 49199 في التجربة يكفي أربعين Allocation فقط.

متى تحتاج TURN فعلاً؟

تحتاجه في ثلاث حالات واضحة: عندما يكون أحد الطرفين خلف NAT يغير المنفذ مع كل وجهة، وهذا ما تفعله بعض أجهزة CGNAT عند مزودي الجوال وبعض أجهزة جدران الحماية في الشركات، وعندما يمنع جدار الحماية حركة UDP الخارجة كلها، وعندما يكون جهاز المستخدم خلف Proxy يفرض مرور كل شيء عبره. أما بين شبكتين منزليتين عاديتين فالطريق المباشر ينجح في الغالب.

وقد يتساءل البعض: ما نسبة المكالمات التي تحتاج TURN؟ والإجابة الصادقة أننا لم نجد رقماً حديثاً منشوراً بمنهجية واضحة لمكالمات WebRTC تحديداً، والأرقام المتداولة في المقالات بلا مصدر أولي، لذلك لا ننقلها. والقياسات المنشورة التي وجدناها تخص الاتصال المباشر عموماً:

  • دراسة Bryan Ford وزملائه المنشورة في مؤتمر USENIX عام 2005 جمعت 380 نتيجة لأجهزة NAT من 68 شركة، ووجدت أن 82% منها تسمح ب Hole Punching عبر UDP، و64% من 286 نتيجة تسمح به عبر TCP، وهي أرقام قديمة لأجهزة منزلية قبل انتشار CGNAT.
  • دراسة Trautwein وزملائه المقبولة في مؤتمر IMC عام 2026 قاست أكثر من 4.4 مليون محاولة اتصال مباشر من أكثر من 85 ألف شبكة في 167 دولة داخل شبكة IPFS، ووجدت أن Hole Punching ينجح في نحو 70% ± 7.1% من المحاولات بعد أن تنجح المراحل التي تسبقه، وهذا بروتوكول libp2p وليس WebRTC، ولكنه يعطيك فكرة حديثة: قرابة محاولة من كل ثلاث تحتاج طريقاً بديلاً.

والرقم الذي يهمك فعلاً هو رقم مستخدميك أنت، وتعرفه من سجلات coturn ومن نوع الزوج المختار في كل مكالمة كما في قسم كيف تعرف أن المكالمة مرت عبر TURN. وإذا كان التطبيق نفسه يستخدم سيرفراً للوسائط بعنوان عام، كما في Mattermost Calls، فلا تحتاج TURN إلا لمن لا يستطيع الوصول إلى منفذ الوسائط، ودليل نشر Calls يقول ذلك صراحة: لا تنشر TURN إذا كان المستخدمون يصلون إلى المنفذ 8443 بال UDP أو TCP، وإذا احتجته فهو يوصي ب coturn.

بيانات الدخول إلى TURN: كلمة مرور ثابتة أم بيانات مؤقتة؟

سيرفر TURN بلا مصادقة هو Relay مفتوح Open Relay يستخدمه أي أحد على الإنترنت لتمرير حركته وإخفاء عنوانه، والمعيار نفسه يذكر في قسم إخفاء مصدر الحركة الضارة أن سيرفرات TURN جذابة للمهاجمين لهذا السبب، لذلك يطلب coturn بيانات دخول، والسؤال هو أين تضعها.

بيانات الدخول طويلة الأمد Long-Term Credentials

الحل البسيط هو اسم مستخدم وكلمة مرور ثابتان في ملف الإعداد بالخيار lt-cred-mech والسطر user=alice:...، وهذا ما سوف نبدأ به التجربة. ولكن لاحظ أين تذهب كلمة المرور: المتصفح يحتاجها ليتصل بالسيرفر، فتوضع في كود JavaScript الذي ترسله لكل زائر، وبالتالي يقرؤها أي أحد يفتح أدوات المطور في المتصفح، ثم يستخدمها من أي مكان وإلى الأبد، ولا تستطيع أن تغيرها إلا وتغير معها إعداد كل العملاء. لذلك فهذا الحل يناسب التجربة وأداة داخلية بين أجهزة تعرفها، ولا يناسب تطبيقاً عاماً.

البيانات المؤقتة عبر سر مشترك Shared Secret

الحل الذي تستخدمه أغلب الخدمات هو أن يصدر سيرفر التطبيق لكل مستخدم بيانات دخول تنتهي بعد مدة، وهي طريقة وصفتها مسودة TURN REST API من Google، وهي مسودة لم تصبح معياراً ولكن coturn يطبقها بالخيار use-auth-secret كما يشرح ملف الإعداد المرجعي، والفكرة كالتالي:

  1. يعرف سيرفر التطبيق وسيرفر TURN سراً مشتركاً واحداً، يوضع في coturn بالخيار static-auth-secret، ولا يغادر السيرفرين أبداً.
  2. عندما يبدأ المستخدم مكالمة، يكون سيرفر التطبيق اسم المستخدم من وقت الانتهاء بصيغة Unix Timestamp ثم نقطتين ثم معرف المستخدم، مثل 1791455645:alice.
  3. كلمة المرور هي base64(HMAC-SHA1(secret, username))، فيرسل الاثنين إلى المتصفح، والمسودة توصي بمدة يوم واحد 86400 ثانية.
  4. عندما يصل الطلب إلى coturn، يحسب كلمة المرور بالطريقة نفسها من السر، ويتأكد أن وقت الانتهاء لم يمر بعد، فلا يحتاج إلى قاعدة بيانات للمستخدمين.

والنتيجة أن من ينسخ بيانات الدخول من المتصفح يستخدمها حتى تنتهي فقط، وأن معرف المستخدم يظهر في سجلات coturn فتعرف من استهلك ال Bandwidth. وتذكر أن نقطة إصدار هذه البيانات في تطبيقك يجب أن تكون خلف تسجيل الدخول، وأن تحدد معدل الطلبات عليها كما شرحنا في الحد من معدل الطلبات Rate Limiting، وإلا أصبحت مصدراً مفتوحاً لبيانات دخول صالحة. وهناك طريقة ثالثة تعتمد على توكن OAuth يعرفها RFC 7635، ولكن دعمها في المكتبات والمتصفحات أقل، لذلك لن نتناولها هنا.

لا تجعل TURN باباً إلى شبكتك الداخلية

هذا أخطر ما في تشغيل TURN، وأقل ما يذكر في الأدلة السريعة. فسيرفر TURN يرسل الحزم إلى أي عنوان يطلبه المستخدم بعد المصادقة، والسيرفر نفسه موجود داخل شبكتك أو داخل شبكة ال VPC عند مزود السحابة، فإذا طلب المستخدم الإذن لعنوان داخلي مثل 10.0.0.5، أو لعنوان خدمة البيانات الوصفية Metadata عند المزود 169.254.169.254، أو لعنوان 127.0.0.1 على السيرفر نفسه، فإن السيرفر يرسل حزمه إلى هناك، أي أن من يملك بيانات دخول صالحة، وقد عرفنا أنها في متصفح كل مستخدم، يصل إلى ما لا يصل إليه الإنترنت، وهي فكرة SSRF نفسها ولكن عبر بروتوكول آخر:

مهاجم يملك بيانات دخول صالحة منسوخة من صفحة التطبيق يرسل طلب CreatePermission للعنوان 10.0.0.5 إلى سيرفر TURN، وأمام الشبكة الداخلية درع denied-peer-ip يرد بالخطأ 403 Forbidden IP على العناوين 169.254.169.254 وخدمة Redis على 10.0.0.5:6379 والعنوان 127.0.0.1 على السيرفر نفسه، بينما تمر حركة المكالمة إلى طرف حقيقي بعنوان عام
القاعدة تغلق الطريق إلى العناوين الداخلية وتترك الطريق إلى الأطراف الحقيقية
📌
في فبراير 2018 وجد فريق Enable Security أن سيرفرات TURN التي تستخدمها Slack للمكالمات تمرر حركة TCP وUDP إلى السيرفر نفسه وإلى عناوين داخلية في شبكة Slack على AWS، ومنها خدمة البيانات الوصفية على 169.254.169.254 التي تعطي مفاتيح IAM مؤقتة، فأبلغوا Slack في أبريل وأصلحت السيرفرات في مايو 2018، ونشر التقرير الكامل في مارس 2020 وفيه قائمة العناوين التي يوصون بحظرها في coturn بالخيار denied-peer-ip، ويمكنك قراءة البلاغ نفسه على HackerOne.

حظر العناوين الداخلية ب denied-peer-ip

coturn يمنع العناوين 127.x.x.x و::1 افتراضياً ما لم تفعل allow-loopback-peers، ولكنه لا يمنع العناوين الخاصة الأخرى، والقرار لك بالخيارين denied-peer-ip وallowed-peer-ip، وفي ملف الإعداد المرجعي قاعدة مهمة: إذا كان العنوان في نطاق محظور وفي نطاق مسموح معاً فهو مسموح، وهذا يفيد عندما تحظر نطاقاً كاملاً وتستثني منه عنواناً أو اثنين. والنطاقات التي يجب حظرها على الأقل هي العناوين الخاصة 10.0.0.0/8 و172.16.0.0/12 و192.168.0.0/16، ونطاق CGNAT 100.64.0.0/10، ونطاق 169.254.0.0/16 الذي تقع فيه خدمة البيانات الوصفية، و0.0.0.0/8 و127.0.0.0/8، ومن IPv6 العناوين المحلية fc00::/7 وfe80::/10 و::1، ونطاق NAT64 64:ff9b::/96 إذا كانت شبكتك تترجم منه إلى IPv4، مع no-multicast-peers. ولاحظ أن coturn يكتب النطاقات بصيغة من عنوان إلى عنوان وليس بصيغة CIDR، كما سوف ترى في ملف التجربة.

ولا تعتمد على هذه القائمة وحدها، والسبب أن ثغرات تجاوز هذه القواعد تتكرر في سجل coturn: فقد كان العنوان 0.0.0.0 وال IPv6 [::1] و[::] يوصل إلى واجهة loopback رغم المنع الافتراضي حتى الإصدار 4.5.2 في CVE-2020-26262، ثم ظهر في 2026 أن صيغة IPv4 داخل IPv6 مثل ::ffff:127.0.0.1 تتجاوز denied-peer-ip في CVE-2026-27624، وتتجاوز منع loopback الافتراضي في CVE-2026-53450 المصلحة في 4.13.0، ثم ظهر تجاوز آخر عبر صيغ IPv6 تترجم إلى IPv4 في CVE-2026-73212 المصلحة في 4.13.1. لذلك تابع صفحة التنبيهات الأمنية وحدث coturn باستمرار، وأضف طبقة ثانية خارجه: جدار حماية على السيرفر أو مجموعة أمان Security Group عند المزود تمنع السيرفر نفسه من الاتصال بشبكتك الداخلية، وضعه في شبكة منفصلة كما شرحنا في تقسيم الشبكة.

حدود ضد إساءة الاستخدام

حتى مع البيانات المؤقتة قد يستخدم أحد بيانات صالحة لاستهلاك سيرفرك، فضع حدوداً من الخيارات التي يشرحها ملف الإعداد: user-quota لعدد الجلسات المتزامنة لكل مستخدم، وtotal-quota لعدد الجلسات في السيرفر كله، وmax-bps لأقصى Bandwidth لكل جلسة، وbps-capacity للسيرفر كله. ولا تفعل واجهة الإدارة عبر Telnet بالخيار cli ولا واجهة الويب بالخيار web-admin على سيرفر عام، فكلاهما معطل افتراضياً في 4.18.0، وقد صدرت في 2026 تنبيهات لثغرات في واجهة الويب ومنها SQL Injection و XSS.

المكالمات الجماعية: Mesh وSFU وMCU

كل ما سبق عن مكالمة بين شخصين، فماذا يحدث مع أربعة؟ RFC 7667 يصف الأشكال الممكنة، وأشهرها ثلاثة:

ثلاثة أجزاء لمكالمة بين أربعة أشخاص: في Mesh يتصل كل شخص بالثلاثة الآخرين فيرسل ثلاث نسخ ويستقبل ثلاثاً، وفي SFU يرسل كل شخص نسخة واحدة إلى السيرفر فيمررها إلى الآخرين دون معالجة، وفي MCU يرسل كل شخص نسخة واحدة ويستقبل نسخة واحدة مدموجة يصنعها السيرفر، مع جدول للإرسال والاستقبال وعمل السيرفر ومتى تحتاج TURN
كلما زاد عدد المشاركين انتقل العبء من أجهزة المستخدمين إلى السيرفر
  • Mesh: يتصل كل شخص بكل شخص اتصالاً مباشراً، فلا يحتاج سيرفراً للوسائط، ولكن كل شخص يرسل نسخة من صوته وصورته لكل مشارك، فيمتلئ اتصاله وجهازه بسرعة، ولهذا يناسب شخصين أو ثلاثة، وفيه يحتاج كل زوج إلى طريق بين الطرفين، فتحتاج TURN بالمقدار الذي ناقشناه أعلاه.
  • SFU، أي Selective Forwarding Unit: يرسل كل شخص نسخة واحدة إلى سيرفر يمررها إلى الآخرين كما هي دون فك الترميز، والمعيار يسميه Selective Forwarding Middlebox، وعليه يبنى Mattermost Calls الذي تصف خدمته rtcd في توثيقها بأنها WebRTC SFU. ولأن السيرفر له عنوان عام، فكل مستخدم يتصل به كما يتصل بأي سيرفر، ولا تحتاج TURN إلا لمن يمنع جدار الحماية عنده منفذ الوسائط.
  • MCU، أي Multipoint Control Unit: يفك السيرفر ترميز كل الأصوات والصور ويدمجها في نسخة واحدة يرسلها لكل شخص، فيقل الحمل على الأجهزة إلى أقصى حد، ولكن السيرفر يحتاج معالجاً قوياً، ويضيف تأخيراً لأنه يفك ترميز كل شيء ثم يعيده.

تجربة عملية: coturn وعميل WebRTC لا يتصل إلا عبر Relay

سوف نبني الآن على جهازك شبكة Docker صغيرة فيها سيرفر coturn، وسيرفر «داخلي» يمثل خدمة في شبكتك لا يجب أن يصل إليها أحد من الخارج، وعميل WebRTC بلغة Python يشغل طرفي مكالمة فيها صوت وقناة بيانات Data Channel، ثم نرى العناوين المرشحة والسجلات، ونخطئ عمداً في كلمة المرور، ونحجب الطريق المباشر، ونحاول الوصول إلى السيرفر الداخلي. والشبكة 10.77.0.0/24 شبكة خاصة، وهذا مقصود لأنها تمثل شبكتك الداخلية، ولاحظ أن الطرفين في التجربة يعملان في Container واحد، لذلك نحجب الطريق المباشر بينهما بقاعدة جدار حماية بدلاً من NAT حقيقي.

تشغيل coturn ببيانات دخول ثابتة

نبدأ بمجلد التجربة وشهادة TLS موقعة ذاتياً Self-Signed للتجربة فقط، ونستخدم أداة openssl الموجودة داخل Docker Image الخاصة ب coturn، ثم نجعل المفتاح مقروءاً لأن coturn يعمل داخل ال Container بالمستخدم nobody:

mkdir lab-turn && cd lab-turn
docker run --rm -v "$PWD:/w" --user 0 --entrypoint openssl coturn/coturn:4.18.0 \
  req -x509 -newkey rsa:2048 -nodes -days 30 -subj /CN=turn.example.com -keyout /w/turn.key -out /w/turn.crt
docker run --rm -v "$PWD:/w" --user 0 --entrypoint chmod coturn/coturn:4.18.0 644 /w/turn.key

وإذا نسيت الأمر الأخير فلن يبدأ coturn مستمع TLS، وسوف ترى في سجله السطر cannot find private key file: /etc/coturn/turn.key ثم The server will NOT listen on that port، مع أن الملف موجود، والسبب أن المستخدم nobody لا يستطيع قراءته. وفي سيرفر حقيقي سوف تستخدم شهادة من Let's Encrypt، والملفان المطلوبان هما fullchain.pem وprivkey.pem كما شرحنا في ملفات Let's Encrypt الأربعة. بعد ذلك نكتب ملف الإعداد turnserver-lt.conf:

listening-port=3478
tls-listening-port=5349
listening-ip=10.77.0.10
relay-ip=10.77.0.10
min-port=49160
max-port=49199
realm=turn.example.com
fingerprint
lt-cred-mech
user=alice:Lab-Pass-2026
cert=/etc/coturn/turn.crt
pkey=/etc/coturn/turn.key
log-file=stdout
simple-log
verbose

في الإعداد أعلاه لاحظ التالي:

  • listening-port هو منفذ STUN وTURN عبر UDP وTCP، وtls-listening-port هو منفذ TLS، والقيمتان 3478 و5349 هما المنفذان المعياريان.
  • relay-ip هو العنوان الذي تحجز عليه منافذ ال Relay، وmin-port وmax-port يحددان نطاقها، وجعلناه صغيراً في التجربة لأن Docker ينشئ قاعدة لكل منفذ ننشره، وهذا سبب توصية coturn بشبكة host في الإنتاج كما سوف نرى.
  • lt-cred-mech يفعل بيانات الدخول طويلة الأمد، وuser= يعرف المستخدم الوحيد، وrealm يدخل في حساب التوقيع، فإذا اختلف بين العميل والسيرفر فشلت المصادقة.
  • fingerprint يضيف بصمة لكل رسالة STUN، وverbose يجعل السجل يكتب كل Allocation وكل إذن، وهذا ما نحتاجه في التجربة، ولا تتركه في الإنتاج مع حركة كبيرة.

الآن ننشئ الشبكة ونشغل coturn بإصدار محدد، وننشر منافذه على 127.0.0.1 فقط حتى لا تكون التجربة مكشوفة على شبكتك:

docker network create --subnet 10.77.0.0/24 lab-turn-net
docker run -d --name lab-turn-coturn --network lab-turn-net --ip 10.77.0.10 \
  -p 127.0.0.1:34780:3478/udp -p 127.0.0.1:34780:3478/tcp -p 127.0.0.1:35349:5349/tcp \
  -v "$PWD/turnserver-lt.conf:/etc/coturn/turnserver.conf:ro" \
  -v "$PWD/turn.crt:/etc/coturn/turn.crt:ro" -v "$PWD/turn.key:/etc/coturn/turn.key:ro" \
  coturn/coturn:4.18.0 -c /etc/coturn/turnserver.conf
docker logs lab-turn-coturn 2>&1 | grep -E "Coturn Version|UDP listener"

والمخرج سوف يكون كما يلي:

2026-10-08T08:58:07.631+0000 INFO Coturn Version Coturn-4.18.0 'Gorst'
2026-10-08T08:58:07.647+0000 INFO IPv4. UDP listener opened on: 10.77.0.10:3478

ثم نشغل السيرفر «الداخلي»، وهو أداة turnutils_peer التي تأتي مع coturn وتعيد كل ما يصلها Echo، على العنوان 10.77.0.30 والمنفذ 3480:

docker run -d --name lab-turn-internal --network lab-turn-net --ip 10.77.0.30 \
  --entrypoint turnutils_peer coturn/coturn:4.18.0 -p 3480 -L 10.77.0.30

فحص سريع بأداة turnutils_uclient

قبل أي عميل WebRTC نتأكد أن السيرفر يعمل بأداة turnutils_uclient، وهي عميل TURN يطلب Allocation ويرسل عبره رسائل إلى طرف محدد وينتظر عودتها، وهذا أول أمر تنفذه بعد تثبيت أي سيرفر TURN:

docker run --rm --name lab-turn-tools --network lab-turn-net --ip 10.77.0.20 \
  --entrypoint turnutils_uclient coturn/coturn:4.18.0 \
  -u alice -w Lab-Pass-2026 -e 10.77.0.30 -r 3480 -n 10 -m 1 -l 120 10.77.0.10

والمخرج سوف يكون كما يلي:

2026-10-08T08:59:20.120+0000 INFO Total connect time is 1
2026-10-08T08:59:23.050+0000 INFO start_mclient: msz=2, tot_send_msgs=10, tot_recv_msgs=10, tot_send_bytes ~ 1200, tot_recv_bytes ~ 1200
2026-10-08T08:59:23.441+0000 INFO start_mclient: tot_send_msgs=20, tot_recv_msgs=20
2026-10-08T08:59:23.441+0000 INFO start_mclient: tot_send_bytes ~ 2400, tot_recv_bytes ~ 2400
2026-10-08T08:59:23.441+0000 INFO Total transmit time is 3
2026-10-08T08:59:23.441+0000 INFO Total lost packets 0 (0.000000%), total send dropped 2 (9.090909%)
2026-10-08T08:59:23.441+0000 INFO Average round trip delay 0.000000 ms; min = 0 ms, max = 0 ms

الخيار -e هو عنوان الطرف الآخر و-r منفذه، و-n 10 عدد الرسائل، و-l 120 حجم كل رسالة بالبايت، والأداة تفتح افتراضياً زوجاً من الجلسات على طريقة RTP وRTCP فيصبح العدد 20 رسالة، وكلها عادت tot_recv_msgs=20 دون فقد. وفي سجل coturn ترى ما حدث خطوة خطوة:

docker logs --since 15s lab-turn-coturn 2>&1 | grep "session 000000000000000001"
2026-10-08T08:59:19.928+0000 INFO allocation new, realm=<turn.example.com>, username=<alice>, lifetime=777 (session 000000000000000001)
2026-10-08T08:59:19.928+0000 INFO realm <turn.example.com> user <alice>: incoming packet ALLOCATE processed, success (session 000000000000000001)
2026-10-08T08:59:19.928+0000 INFO allocation refreshed, realm=<turn.example.com>, username=<alice>, lifetime=777 (session 000000000000000001)
2026-10-08T08:59:19.928+0000 INFO realm <turn.example.com> user <alice>: incoming packet REFRESH processed, success (session 000000000000000001)
2026-10-08T08:59:19.929+0000 INFO peer 10.77.0.30:3481 lifetime updated: 600 (session 000000000000000001)
2026-10-08T08:59:19.929+0000 INFO realm <turn.example.com> user <alice>: incoming packet CHANNEL_BIND processed, success (session 000000000000000001)
2026-10-08T08:59:20.130+0000 INFO allocation refreshed, realm=<turn.example.com>, username=<alice>, lifetime=600 (session 000000000000000001)
2026-10-08T08:59:20.130+0000 INFO realm <turn.example.com> user <alice>: incoming packet REFRESH processed, success (session 000000000000000001)
2026-10-08T08:59:20.130+0000 INFO peer 10.77.0.30:3481 lifetime updated: 300 (session 000000000000000001)
2026-10-08T08:59:20.130+0000 INFO realm <turn.example.com> user <alice>: incoming packet CREATE_PERMISSION processed, success (session 000000000000000001)
2026-10-08T08:59:20.131+0000 INFO peer 10.77.0.30:3481 lifetime updated: 600 (session 000000000000000001)
2026-10-08T08:59:20.131+0000 INFO realm <turn.example.com> user <alice>: incoming packet CHANNEL_BIND processed, success (session 000000000000000001)

ولاحظ في السجل أعلاه الخطوات التي شرحناها في قسم ال Allocation: ALLOCATE ثم REFRESH لتجديد العمر، ثم CHANNEL_BIND بعمر 600 ثانية وهي الدقائق العشر للقناة، وCREATE_PERMISSION بعمر 300 ثانية وهي الدقائق الخمس التي يحددها المعيار للإذن. ولاحظ أيضاً شيئاً أخطر: الطرف 10.77.0.30 سيرفر داخلي في شبكتنا، ومع ذلك وصلت إليه الرسائل عبر TURN دون أي اعتراض، وهذه هي المشكلة التي شرحناها في قسم الأمان، وسوف نغلقها بعد قليل.

كلمة مرور خاطئة: الخطأ 401

الآن نكسر المصادقة عمداً بكلمة مرور خاطئة، ونضيف -c حتى تفتح الأداة جلسة واحدة دون RTCP:

docker run --rm --name lab-turn-tools --network lab-turn-net --ip 10.77.0.20 \
  --entrypoint turnutils_uclient coturn/coturn:4.18.0 \
  -c -u alice -w wrong-pass -e 10.77.0.30 -r 3480 -n 10 -m 1 -l 120 10.77.0.10

وعند تشغيل الأمر سوف تجد أن الأداة تحاول عدة مرات قرابة 15 ثانية ثم تتوقف بهذا السطر:

2026-10-08T08:59:57.454+0000 ERROR Cannot complete Allocation

والسبب الحقيقي تجده في سجل coturn وليس عند العميل:

2026-10-08T08:59:57.454+0000 ERROR user alice credentials are incorrect (session 006000000000000032, check_stun_auth)
2026-10-08T08:59:57.454+0000 INFO realm <turn.example.com> user <alice>: incoming packet message processed, error 401: Unauthorized (session 006000000000000032)

وتذكر أن الخطأ 401 في أول طلب من كل Allocation طبيعي كما ذكرنا، لأنه الرد الذي يحمل ال Nonce، أما 401 بعد طلب موقع ومعه السطر credentials are incorrect فهو كلمة مرور خاطئة أو Realm مختلف، وفي المتصفح سوف ترى النتيجة نفسها بشكل آخر: لا يظهر أي عنوان من نوع relay في قائمة العناوين المرشحة، دون أي رسالة خطأ واضحة.

TCP وTLS للشبكات التي تحجب UDP

نعيد الفحص عبر TCP بالخيار -t، ثم عبر TLS على المنفذ 5349 بالخيارين -S -t، وهذا هو الطريق الذي يستخدمه المتصفح عندما يكون UDP محجوباً:

docker run --rm --name lab-turn-tools --network lab-turn-net --ip 10.77.0.20 \
  --entrypoint turnutils_uclient coturn/coturn:4.18.0 \
  -S -t -p 5349 -c -u alice -w Lab-Pass-2026 -e 10.77.0.30 -r 3480 -n 10 -m 1 -l 120 10.77.0.10

والمخرج سوف يكون كما يلي:

2026-10-08T09:00:17.182+0000 INFO tls_connect: client session connected with cipher TLS_AES_256_GCM_SHA384, method=TLSv1.3
2026-10-08T09:00:17.295+0000 INFO Total connect time is 0
2026-10-08T09:00:18.595+0000 INFO start_mclient: tot_send_msgs=10, tot_recv_msgs=10
2026-10-08T09:00:18.595+0000 INFO start_mclient: tot_send_bytes ~ 1200, tot_recv_bytes ~ 1200
2026-10-08T09:00:18.595+0000 INFO Total lost packets 0 (0.000000%), total send dropped 0 (0.000000%)

وفي سجل coturn يظهر ال Allocation ومعه نوع التشفير، وهذا يفيدك لتعرف كم من مستخدميك يصلون عبر TLS، أي من خلف جدران حماية تحجب UDP:

2026-10-08T09:00:17.190+0000 INFO allocation new, realm=<turn.example.com>, username=<alice>, lifetime=777, cipher=TLS_AES_256_GCM_SHA384, method=TLSv1.3 (session 003000000000000003)

عميل WebRTC بلغة Python

الأداة السابقة تختبر TURN وحده، أما الآن فنريد مكالمة WebRTC حقيقية فيها ICE وDTLS وصوت، لذلك نستخدم مكتبة aiortc الإصدار 1.15.0، وهي تطبيق WebRTC كامل بلغة Python يعتمد على مكتبة aioice لل ICE. ننشئ Container للعميل ونثبت المكتبة، ونعطيه الصلاحية NET_ADMIN لأننا سوف نحتاج جدار الحماية فيه بعد قليل:

docker run -d --name lab-turn-client --cap-add NET_ADMIN --network lab-turn-net --ip 10.77.0.40 python:3.13-slim-trixie sleep 7200
docker exec lab-turn-client pip install -q --root-user-action=ignore aiortc==1.15.0

ثم نكتب الملف call.py، وفيه طرفان A وB في برنامج واحد، يرسل A صوتاً وقناة بيانات، ونمرر العرض والجواب بينهما مباشرة في الذاكرة بدلاً من سيرفر تنسيق، ثم نطبع العناوين المرشحة من ال SDP والزوج الذي اختاره ICE:

# Two WebRTC peers in one process: an audio track and a data channel through coturn.
#   python call.py --policy relay --user alice --password ...       (long-term credentials)
#   python call.py --policy relay --secret ... --ttl 3600            (time-limited REST credentials)
import argparse, asyncio, base64, hashlib, hmac, time
import aiortc.rtcicetransport as rit
from aioice import TransportPolicy
from aiortc import RTCPeerConnection, RTCConfiguration, RTCIceServer, RTCBundlePolicy
from aiortc.mediastreams import AudioStreamTrack

p = argparse.ArgumentParser()
p.add_argument("--turn", default="10.77.0.10:3478")
p.add_argument("--policy", choices=["all", "relay"], default="relay")
p.add_argument("--user"); p.add_argument("--password")
p.add_argument("--secret"); p.add_argument("--ttl", type=int, default=3600)
a = p.parse_args()

if a.secret:  # TURN REST API: username = expiry:user, password = base64(HMAC-SHA1(secret, username))
    a.user = f"{int(time.time()) + a.ttl}:alice"
    a.password = base64.b64encode(hmac.new(a.secret.encode(), a.user.encode(), hashlib.sha1).digest()).decode()
    print("username:", a.user)
    print("password:", a.password)

if a.policy == "relay":  # aiortc has no iceTransportPolicy option, so we pass aioice's RELAY policy
    _Connection = rit.Connection
    rit.Connection = lambda *x, **kw: _Connection(*x, transport_policy=TransportPolicy.RELAY, **kw)

servers = [RTCIceServer(urls=f"turn:{a.turn}?transport=udp", username=a.user, credential=a.password)]
if a.policy == "all":
    servers.insert(0, RTCIceServer(urls=f"stun:{a.turn}"))
config = RTCConfiguration(iceServers=servers, bundlePolicy=RTCBundlePolicy.MAX_BUNDLE)

def candidates(name, sdp):
    seen = []
    for line in sdp.splitlines():  # the audio and data sections share one bundled transport
        if line.startswith("a=candidate:") and line not in seen:
            seen.append(line); f = line.split()
            print(f"  {name} {f[7]:6} {f[4]}:{f[5]}")
    if not seen:
        print(f"  {name} gathered no candidates")

def chosen(pc):
    conn = pc.sctp.transport.transport._connection
    pair = conn._nominated.get(1)
    if pair:
        l, r = pair.local_candidate, pair.remote_candidate
        return f"local {l.type} {l.host}:{l.port}  ->  remote {r.type} {r.host}:{r.port}"

async def main():
    # print refused TURN requests in one line instead of a traceback
    asyncio.get_running_loop().set_exception_handler(lambda loop, ctx: print("TURN error:", ctx.get("exception")))
    A, B = RTCPeerConnection(config), RTCPeerConnection(config)
    A.addTrack(AudioStreamTrack())
    chat = A.createDataChannel("chat")
    frames, got = 0, asyncio.get_running_loop().create_future()

    @B.on("track")
    def on_track(track):
        async def read():
            nonlocal frames
            while frames < 50:
                await track.recv(); frames += 1
        asyncio.ensure_future(read())

    @B.on("datachannel")
    def on_dc(ch):
        ch.on("message", lambda m: ch.send("pong"))
    chat.on("message", lambda m: got.done() or got.set_result(m))

    t0 = time.monotonic()
    await A.setLocalDescription(await A.createOffer())
    print("offer candidates (A):"); candidates("A", A.localDescription.sdp)
    await B.setRemoteDescription(A.localDescription)
    await B.setLocalDescription(await B.createAnswer())
    print("answer candidates (B):"); candidates("B", B.localDescription.sdp)
    await A.setRemoteDescription(B.localDescription)

    try:
        await asyncio.wait_for(asyncio.shield(chat_open(chat)), 20)
        sent = time.monotonic(); chat.send("ping")
        reply = await asyncio.wait_for(got, 5)
        rtt = (time.monotonic() - sent) * 1000
        await asyncio.sleep(1.5)
        print(f"ICE state: {A.iceConnectionState}, connected after {sent - t0:.2f}s")
        print("selected pair:", chosen(A))
        print(f"data channel: sent ping, got {reply} in {rtt:.1f} ms")
        print(f"audio: B received {frames} frames of 20 ms from A")
    except asyncio.TimeoutError:
        print(f"ICE state: {A.iceConnectionState} after 20s, no connection")
    await A.close(); await B.close()

async def chat_open(ch):
    while ch.readyState != "open":
        await asyncio.sleep(0.05)

asyncio.run(main())

في الكود أعلاه لاحظ التالي:

  • في المتصفح تجبر المكالمة على ال Relay بالخيار iceTransportPolicy: "relay" الذي يعرفه معيار WebRTC من W3C، أما aiortc فلا يوفر هذا الخيار في الإصدار 1.15.0، لذلك نمرر سياسة TransportPolicy.RELAY مباشرة إلى aioice الذي يدعمها، وهذا هو السطر الوحيد الذي يتجاوز الواجهة العامة للمكتبة، ومعه دالة chosen التي تقرأ الزوج المختار من داخل aioice.
  • مع --policy all نضيف سيرفر STUN على العنوان نفسه، فيجمع كل طرف الأنواع الثلاثة، ومع --policy relay يجمع عنوان ال Relay فقط.
  • MAX_BUNDLE يجعل الصوت وقناة البيانات يشتركان في اتصال واحد، وهذا ما تفعله المتصفحات افتراضياً، فتظهر قائمة واحدة من العناوين.
  • مع --secret يحسب البرنامج بيانات الدخول المؤقتة بالطريقة التي شرحناها في قسم السر المشترك، وهذه الأسطر الثلاثة هي ما تضعه في سيرفر تطبيقك.

ننسخ الملف إلى ال Container ونشغله أولاً دون أي قيود:

docker cp call.py lab-turn-client:/call.py
docker exec lab-turn-client python /call.py --policy all --user alice --password Lab-Pass-2026

والمخرج سوف يكون كما يلي:

offer candidates (A):
  A host   10.77.0.40:43072
  A relay  10.77.0.10:49168
  A srflx  10.77.0.40:43072
answer candidates (B):
  B host   10.77.0.40:42300
  B relay  10.77.0.10:49162
  B srflx  10.77.0.40:42300
ICE state: completed, connected after 0.07s
selected pair: local host 10.77.0.40:43072  ->  remote host 10.77.0.40:42300
data channel: sent ping, got pong in 1.5 ms
audio: B received 50 frames of 20 ms from A

كل طرف جمع الأنواع الثلاثة، ولأن الطرفين على الشبكة نفسها نجح الزوج host مع host واختاره ICE لأنه الأعلى أولوية، ولاحظ أن العنوان srflx هنا هو عنوان host نفسه، لأنه لا يوجد NAT بين العميل وسيرفر STUN في التجربة، وفي شبكة حقيقية سوف ترى فيه العنوان العام. الآن نجبر الطرفين على ال Relay:

docker exec lab-turn-client python /call.py --policy relay --user alice --password Lab-Pass-2026
offer candidates (A):
  A relay  10.77.0.10:49193
answer candidates (B):
  B relay  10.77.0.10:49198
ICE state: completed, connected after 0.18s
selected pair: local relay 10.77.0.10:49193  ->  remote relay 10.77.0.10:49198
data channel: sent ping, got pong in 1.8 ms
audio: B received 50 frames of 20 ms from A

لا يوجد في ال SDP إلا عنوان ال Relay، ومر الصوت والبيانات عبر coturn بين منفذين من النطاق 49160 إلى 49199، وهذا ما تفعله في المتصفح عندما تريد أن تتأكد أن سيرفر TURN يعمل فعلاً، لأن المكالمة مع السياسة العادية قد تنجح عبر الطريق المباشر فتخفي عنك أن TURN معطل.

نحجب الطريق المباشر ونرى ICE يختار ال Relay

الآن نحاكي شبكة تمنع الطريق المباشر، فنضيف في ال Container قاعدة تسقط حزم UDP بين عنوانيه هو، وهي الحزم التي يتبادلها الطرفان مباشرة، أما الحزم إلى coturn ومنه فتمر عبر الواجهة eth0 فلا تتأثر، ثم نشغل المكالمة بالسياسة العادية:

docker exec lab-turn-client sh -c 'apt-get update -qq && apt-get install -y -qq iptables >/dev/null 2>&1'
docker exec lab-turn-client iptables -A INPUT -i lo -p udp -s 10.77.0.40 -d 10.77.0.40 -j DROP
docker exec lab-turn-client python /call.py --policy all --user alice --password Lab-Pass-2026
docker exec lab-turn-client iptables -L INPUT -n -v

والمخرج سوف يكون كما يلي:

offer candidates (A):
  A host   10.77.0.40:45390
  A srflx  10.77.0.40:45390
  A relay  10.77.0.10:49182
answer candidates (B):
  B host   10.77.0.40:58439
  B relay  10.77.0.10:49175
  B srflx  10.77.0.40:58439
ICE state: completed, connected after 0.12s
selected pair: local host 10.77.0.40:45390  ->  remote relay 10.77.0.10:49175
Chain INPUT (policy ACCEPT 114 packets, 11102 bytes)
 pkts bytes target     prot opt in     out     source               destination
    4   472 DROP       udp  --  lo     *       10.77.0.40           10.77.0.40

هنا نتيجة مهمة: لم يختر ICE زوج relay مع relay، وإنما اختار عنوان A المحلي مع عنوان ال Relay الخاص ب B، أي أن A يرسل مباشرة إلى عنوان B على سيرفر TURN، والسيرفر يمرر إلى B، وهذا يكفي لأن الطريق من A إلى سيرفر TURN مفتوح. ولاحظ عداد القاعدة: أربع حزم سقطت، وهي فحوص ICE على الأزواج المباشرة التي فشلت قبل أن ينجح الزوج الذي يمر عبر ال Relay. وهذا بالضبط ما يحدث في الواقع عندما يكون أحد الطرفين فقط خلف شبكة صعبة: يحتاج ال Relay طرف واحد، ويستخدمه الآخر كعنوان عادي.

البيانات المؤقتة وحظر العناوين الداخلية

الآن ننتقل إلى الإعداد الذي يناسب الإنتاج: سر مشترك بدلاً من كلمة المرور، وحدود للجلسات، وحظر العناوين الداخلية. نولد السر أولاً:

openssl rand -hex 32
f21ccbe9f07c65b5691ff2ae5124b14e5a299873ec7603e8b4e93395c4a7aab4

ثم نكتب turnserver-rest.conf، وضع فيه السر الذي ولدته أنت وليس السر أعلاه:

listening-port=3478
tls-listening-port=5349
listening-ip=10.77.0.10
relay-ip=10.77.0.10
min-port=49160
max-port=49199
realm=turn.example.com
fingerprint
use-auth-secret
static-auth-secret=f21ccbe9f07c65b5691ff2ae5124b14e5a299873ec7603e8b4e93395c4a7aab4
cert=/etc/coturn/turn.crt
pkey=/etc/coturn/turn.key
user-quota=12
total-quota=1200
no-multicast-peers
denied-peer-ip=0.0.0.0-0.255.255.255
denied-peer-ip=10.0.0.0-10.255.255.255
denied-peer-ip=100.64.0.0-100.127.255.255
denied-peer-ip=127.0.0.0-127.255.255.255
denied-peer-ip=169.254.0.0-169.254.255.255
denied-peer-ip=172.16.0.0-172.31.255.255
denied-peer-ip=192.168.0.0-192.168.255.255
denied-peer-ip=::1
denied-peer-ip=64:ff9b::-64:ff9b::ffff:ffff
denied-peer-ip=fc00::-fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff
denied-peer-ip=fe80::-febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff
allowed-peer-ip=10.77.0.10
log-file=stdout
simple-log
verbose

في الإعداد أعلاه لاحظ التالي:

  • اختفى lt-cred-mech وسطر المستخدم، وحل محلهما use-auth-secret وstatic-auth-secret، وملف الإعداد المرجعي ينبه إلى ألا تجمع بين الطريقتين في ملف واحد.
  • أسطر denied-peer-ip هي النطاقات التي ذكرناها في قسم الحظر، مكتوبة بصيغة من عنوان إلى عنوان.
  • السطر allowed-peer-ip=10.77.0.10 خاص بالتجربة، وسوف نشرح سببه بعد قليل.

نعيد تشغيل coturn بالملف الجديد بالأمر نفسه بعد أن نحذف ال Container السابق:

docker rm -f lab-turn-coturn
docker run -d --name lab-turn-coturn --network lab-turn-net --ip 10.77.0.10 \
  -p 127.0.0.1:34780:3478/udp -p 127.0.0.1:34780:3478/tcp -p 127.0.0.1:35349:5349/tcp \
  -v "$PWD/turnserver-rest.conf:/etc/coturn/turnserver.conf:ro" \
  -v "$PWD/turn.crt:/etc/coturn/turn.crt:ro" -v "$PWD/turn.key:/etc/coturn/turn.key:ro" \
  coturn/coturn:4.18.0 -c /etc/coturn/turnserver.conf

ثم نجري مكالمة عبر ال Relay فقط ببيانات مؤقتة مدتها ساعة، ونقرأ سجل coturn بعدها:

docker exec lab-turn-client python /call.py --policy relay --secret f21ccbe9f07c65b5691ff2ae5124b14e5a299873ec7603e8b4e93395c4a7aab4 --ttl 3600
docker logs --since 40s lab-turn-coturn 2>&1 | grep -E "allocation new|CHANNEL_BIND|usage"

والمخرج سوف يكون كما يلي:

username: 1791455645:alice
password: 60FtxH2uV0LZ0rcvicqj8jFPfo4=
offer candidates (A):
  A relay  10.77.0.10:49167
answer candidates (B):
  B relay  10.77.0.10:49198
ICE state: completed, connected after 0.11s
selected pair: local relay 10.77.0.10:49167  ->  remote relay 10.77.0.10:49198
data channel: sent ping, got pong in 2.4 ms
audio: B received 50 frames of 20 ms from A
2026-10-08T09:34:05.719+0000 INFO allocation new, realm=<turn.example.com>, username=<1791455645:alice>, lifetime=600 (session 004000000000000001)
2026-10-08T09:34:05.725+0000 INFO allocation new, realm=<turn.example.com>, username=<1791455645:alice>, lifetime=600 (session 005000000000000002)
2026-10-08T09:34:05.748+0000 INFO realm <turn.example.com> user <1791455645:alice>: incoming packet CHANNEL_BIND processed, success (session 005000000000000002)
2026-10-08T09:34:05.748+0000 INFO realm <turn.example.com> user <1791455645:alice>: incoming packet CHANNEL_BIND processed, success (session 004000000000000001)
2026-10-08T09:34:08.333+0000 INFO usage: realm=<turn.example.com>, username=<1791455645:alice>, rp=14, rb=1914, sp=94, sb=5769 (session 005000000000000002)
2026-10-08T09:34:08.333+0000 INFO peer usage: realm=<turn.example.com>, username=<1791455645:alice>, rp=92, rb=5301, sp=11, sb=1510 (session 005000000000000002)
2026-10-08T09:34:08.336+0000 INFO usage: realm=<turn.example.com>, username=<1791455645:alice>, rp=94, rb=5977, sp=14, sb=1706 (session 004000000000000001)
2026-10-08T09:34:08.336+0000 INFO peer usage: realm=<turn.example.com>, username=<1791455645:alice>, rp=12, rb=1554, sp=91, sb=5573 (session 004000000000000001)

اسم المستخدم هو وقت الانتهاء ثم alice، والسيرفر قبله دون أن يعرف شيئاً عن alice مسبقاً، لأنه حسب كلمة المرور نفسها من السر. ولاحظ أسطر usage التي يكتبها coturn عند إغلاق كل Allocation: rp وrb هما عدد الحزم والبايتات التي استقبلها من العميل، وsp وsb ما أرسله إليه، وpeer usage الأرقام نفسها مع الطرف الآخر، ومع معرف المستخدم في الاسم تستطيع أن تجمع منها استهلاك كل مستخدم، وهو الرقم الذي تحتاجه لتعرف تكلفة ال Relay في قسم ال Bandwidth.

محاولة الوصول إلى سيرفر داخلي: الخطأ 403

الآن نعيد الاختبار الذي وصل فيه turnutils_uclient إلى السيرفر الداخلي 10.77.0.30، ولكن ببيانات دخول صحيحة من السر المشترك، فالخيار -W يجعل الأداة تحسب البيانات المؤقتة بنفسها، وهذا هو موقف من نسخ بيانات صالحة من متصفحه:

docker run --rm --name lab-turn-tools --network lab-turn-net --ip 10.77.0.20 \
  --entrypoint turnutils_uclient coturn/coturn:4.18.0 \
  -c -W f21ccbe9f07c65b5691ff2ae5124b14e5a299873ec7603e8b4e93395c4a7aab4 -u alice -e 10.77.0.30 -r 3480 -n 10 -m 1 -l 120 10.77.0.10
2026-10-08T09:04:16.043+0000 INFO channel bind: error 403 ()

وفي سجل coturn السبب واضحاً:

2026-10-08T09:04:16.042+0000 INFO realm <turn.example.com> user <1791536656:alice>: incoming packet ALLOCATE processed, success (session 003000000000000001)
2026-10-08T09:04:16.043+0000 ERROR A peer IP 10.77.0.30 denied in the range: 10.0.0.0-10.255.255.255 in server 3 (session 003000000000000001)
2026-10-08T09:04:16.043+0000 INFO realm <turn.example.com> user <1791536656:alice>: incoming packet CHANNEL_BIND processed, error 403: Forbidden IP (session 003000000000000001)

ال Allocation نجح لأن البيانات صحيحة، ولكن طلب الوصول إلى العنوان الداخلي رفض بالخطأ 403: Forbidden IP، وهذا ما نريده تماماً: المصادقة تحدد من يستخدم السيرفر، وقواعد العناوين تحدد إلى أين يصل.

ثم نعيد المكالمة بالسياسة العادية، والقاعدة التي تحجب الطريق المباشر ما زالت في ال Container:

docker exec lab-turn-client python /call.py --policy all --secret f21ccbe9f07c65b5691ff2ae5124b14e5a299873ec7603e8b4e93395c4a7aab4
username: 1791455633:alice
password: 7EC+/7MnxD+rSEWdR4MZx5UXRcM=
offer candidates (A):
  A host   10.77.0.40:40863
  A srflx  10.77.0.40:40863
  A relay  10.77.0.10:49183
answer candidates (B):
  B host   10.77.0.40:42782
  B srflx  10.77.0.40:42782
  B relay  10.77.0.10:49188
TURN error: STUN transaction failed (403 - )
TURN error: STUN transaction failed (403 - )
ICE state: completed, connected after 0.19s
selected pair: local relay 10.77.0.10:49183  ->  remote relay 10.77.0.10:49188
data channel: sent ping, got pong in 1.8 ms
audio: B received 50 frames of 20 ms from A
2026-10-08T09:33:53.764+0000 ERROR A peer IP 10.77.0.40 denied in the range: 10.0.0.0-10.255.255.255 in server 5 (session 005000000000000001)
2026-10-08T09:33:53.764+0000 INFO realm <turn.example.com> user <1791455633:alice>: incoming packet CHANNEL_BIND processed, error 403: Forbidden IP (session 005000000000000001)
2026-10-08T09:33:53.827+0000 INFO realm <turn.example.com> user <1791455633:alice>: incoming packet CHANNEL_BIND processed, success (session 005000000000000001)

قارن هذه النتيجة بنتيجة الحجب السابق: هناك اختار ICE عنوان A المحلي مع عنوان Relay الخاص ب B، أما الآن فعنوان A المحلي 10.77.0.40 من نطاق خاص محظور، فرفض coturn أن يمرر إليه بالخطأ 403، وهما سطرا TURN error في المخرج، فاستمر ICE إلى الزوج التالي relay مع relay ونجح. وهذا يعني أن حظر العناوين الخاصة لا يكسر المكالمات الحقيقية، لأن الطرف الذي يستخدم ال Relay يتحدث مع الأطراف عبر عناوينها العامة، والعنوان الخاص لا يفيد أصلاً من خارج شبكته.

لماذا احتجنا allowed-peer-ip في التجربة؟

في التجربة عنوان ال Relay نفسه 10.77.0.10 من النطاق الخاص 10.0.0.0/8، فعندما يرسل A إلى عنوان Relay الخاص ب B يطلب من coturn الإذن لعنوان محظور. لنحذف السطر allowed-peer-ip من الملف ونعيد تشغيل coturn ونجري المكالمة عبر ال Relay فقط:

sed '/^allowed-peer-ip/d' turnserver-rest.conf > turnserver-noallow.conf
offer candidates (A):
  A relay  10.77.0.10:49188
answer candidates (B):
  B relay  10.77.0.10:49178
TURN error: STUN transaction failed (403 - )
TURN error: STUN transaction failed (403 - )
ICE state: checking after 20s, no connection
2026-10-08T09:06:24.493+0000 ERROR A peer IP 10.77.0.10 denied in the range: 10.0.0.0-10.255.255.255 in server 2 (session 002000000000000001)
2026-10-08T09:06:24.493+0000 ERROR A peer IP 10.77.0.10 denied in the range: 10.0.0.0-10.255.255.255 in server 5 (session 005000000000000001)

بقيت حالة ICE checking حتى انتهت المهلة، لأن كل زوج ممكن يمر بعنوان محظور. وفي سيرفر حقيقي لن تواجه هذا، لأن عنوان ال Relay الذي يراه الطرفان هو العنوان العام للسيرفر، وهو خارج النطاقات المحظورة، ولكن تذكر هذه الحالة إذا شغلت TURN داخل شبكة خاصة لموظفين داخليين، فعندها تستثني عنوان السيرفر نفسه بالخيار allowed-peer-ip كما فعلنا.

بيانات دخول منتهية

آخر اختبار هو بيانات دخول انتهت قبل دقيقة، كما يحدث عندما يترك المستخدم الصفحة مفتوحة أطول من مدة البيانات، أو عندما تختلف ساعة سيرفر التطبيق عن ساعة سيرفر TURN:

docker exec lab-turn-client python /call.py --policy relay --secret f21ccbe9f07c65b5691ff2ae5124b14e5a299873ec7603e8b4e93395c4a7aab4 --ttl -60
username: 1791451927:alice
password: Golgr/BsNTJcApIIIILU1pmkaYI=
offer candidates (A):
  A gathered no candidates
answer candidates (B):
  B gathered no candidates
ICE state: new after 20s, no connection
2026-10-08T09:33:07.970+0000 ERROR credentials of user <1791451927:alice> have expired (the time-limited username timestamp is in the past)
2026-10-08T09:33:07.970+0000 INFO realm <turn.example.com> user <1791451927:alice>: incoming packet message processed, error 401: Unauthorized (session 006000000000000001)

العميل لم يجمع أي عنوان، دون رسالة خطأ تشرح السبب، وهذا ما سوف تراه في المتصفح أيضاً، أما السجل فيقول السبب حرفياً. لذلك عندما تختفي عناوين relay عند المستخدمين ابدأ بسجل coturn، وتأكد أن ساعة السيرفرين متزامنة عبر NTP.

إزالة التجربة

نحذف ما أنشأناه فقط بأسمائه:

docker rm -f lab-turn-coturn lab-turn-client lab-turn-internal
docker network rm lab-turn-net
docker rmi coturn/coturn:4.18.0

إعداد coturn لسيرفر حقيقي

الإعداد في الإنتاج هو ملف التجربة الثاني نفسه مع اختلافات قليلة، وكل منها له سبب:

  • شبكة host: توثيق Docker Image الرسمية يوصي بتشغيل coturn بالخيار --network=host، لأن Docker يتعامل بشكل سيئ مع نطاقات المنافذ الكبيرة، وقد شرحنا شبكة host وحدودها في شبكات Docker.
  • العنوان العام: إذا كان السيرفر خلف NAT عند المزود، كما في كثير من السحابات حيث يرى السيرفر عنواناً خاصاً، فأضف external-ip=203.0.113.10/10.0.0.4 بالعنوان العام ثم الخاص، والسبب أن coturn يعيد هذا العنوان للعميل في XOR-RELAYED-ADDRESS، فإذا أعاد العنوان الخاص لم يصل إليه أحد، وهي المشكلة نفسها التي حلها ICE Host Override في إعداد Mattermost Calls.
  • المنفذ 443: لمن يصلون من شبكات لا تسمح إلا ب HTTPS، اجعل tls-listening-port=443 بشهادة حقيقية لاسم مثل turn.example.com، وهذا يتطلب أن يكون للسيرفر عنوان لا يستخدم فيه ال Reverse Proxy المنفذ 443، لذلك يوضع TURN غالباً على سيرفر أو عنوان مستقل.
  • المنافذ في جدار الحماية: افتح 3478 بال UDP وTCP، و443 أو 5349 بال TCP، ونطاق ال Relay بال UDP كما حددته في min-port وmax-port، والقيمة الافتراضية من 49152 إلى 65535، وتذكر أن حجم النطاق هو أقصى عدد من ال Allocations المتزامنة.
  • بقية الخيارات: احذف verbose وallowed-peer-ip الخاص بالتجربة، وأبق الحدود وقواعد الحظر، وراجع قائمة الخيارات الكاملة قبل أن تضيف شيئاً لا تعرفه.

أما في التطبيق فإعداد المتصفح يكون بهذا الشكل، مع بيانات مؤقتة يرسلها سيرفرك لكل مستخدم:

const pc = new RTCPeerConnection({
  iceServers: [{
    urls: ["turn:turn.example.com:3478?transport=udp", "turns:turn.example.com:443?transport=tcp"],
    username: "1791455645:alice",
    credential: "60FtxH2uV0LZ0rcvicqj8jFPfo4="
  }]
});

كيف تعرف أن المكالمة مرت عبر TURN؟

من جهة السيرفر تراها في سجل coturn كما في التجربة، فكل مكالمة عبر ال Relay تظهر بسطر allocation new وأسطر usage بمعرف المستخدم. ومن جهة المتصفح فإن معيار الإحصاءات في WebRTC يوفر عبر الدالة getStats() تقريراً من نوع transport فيه selectedCandidatePairId، ومنه تصل إلى الزوج المختار ثم إلى العنوان المحلي، وفيه الحقل candidateType بقيمة host أو srflx أو prflx أو relay، فإذا جمعت هذا الحقل من مكالمات المستخدمين عرفت النسبة الحقيقية التي تحتاج TURN عندك. وفي Chrome تجد المعلومات نفسها في الصفحة chrome://webrtc-internals أثناء المكالمة.

مشكلات شائعة وكيف تشخصها

لا يظهر أي عنوان من نوع relay

السبب إما بيانات دخول خاطئة أو منتهية، وتعرفها من سطر credentials are incorrect أو have expired في سجل coturn كما في التجربة، أو أن العميل لا يصل إلى منفذ السيرفر أصلاً، وعندها لا يظهر للعميل أي سطر في السجل، فتأكد من المنفذ 3478 في جدار الحماية عند المزود، واختبر من خارج الشبكة بأداة turnutils_uclient، وأضف عنوان turns: على 443 لمن يحجب UDP.

يظهر عنوان relay ولا يصل الصوت

السبب في الغالب أن نطاق منافذ ال Relay مغلق في جدار الحماية، فال Allocation ينجح على المنفذ 3478 ثم تضيع الحزم على المنافذ من min-port إلى max-port، أو أن السيرفر خلف NAT ويعيد عنوانه الخاص لأنك لم تضبط external-ip، فقارن العنوان في أسطر a=candidate من نوع relay بالعنوان العام للسيرفر.

الخطأ 403 Forbidden IP في السجل

إذا ظهر لعنوان خاص فهذه قواعد الحظر تعمل كما يجب، ولا تزلها، أما إذا فشلت المكالمات كلها معه فالسيرفر يعيد عنوان Relay من نطاق محظور كما في التجربة، والحل external-ip بالعنوان العام، أو allowed-peer-ip لعنوان السيرفر نفسه في شبكة داخلية.

المكالمة تعمل من البيت وتفشل من المكتب

هذه هي الحالة التي بدأنا بها المقال، والسبب أن جدار الحماية في المكتب يمنع UDP أو يفرض Proxy، والحل سيرفر TURN بعنوان turns: على المنفذ 443، ثم تتأكد بالسياسة relay من شبكة المكتب أن العنوان من نوع relay يظهر ويعمل.

الخطأ 486 Allocation Quota Reached

وصل المستخدم إلى الحد الذي وضعته في user-quota، أو وصل السيرفر إلى total-quota، فإذا كان المستخدم حقيقياً ويفتح عدة نوافذ ارفع الحد قليلاً، وإذا كان معرفاً واحداً يفتح مئات الجلسات فهو إساءة استخدام، وتعرفه من معرف المستخدم في السجل.

الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

  • في WebRTC طريقان: قناة التنسيق عبر سيرفرك تحمل عرض SDP وجوابه، والصوت والصورة يذهبان مباشرة، لذلك تتصل المكالمة أحياناً ولا يصل الصوت.
  • ICE يجمع ثلاثة أنواع من العناوين، host وsrflx وrelay، ويختبر الأزواج بطلبات STUN، ويفضل الأقصر، وال Relay آخر الحلول.
  • STUN يخبرك بعنوانك ولا يحمل صوتاً ولا يكلف شيئاً، وTURN يمرر كل حزمة فيكلفك 2X داخل و2X خارج لكل مكالمة، ويحتاجه من خلف NAT يغير المنفذ مع كل وجهة، أو جدار حماية يمنع UDP، فاجعل له عنواناً عبر TLS على 443.
  • لا يوجد رقم حديث موثوق لنسبة مكالمات WebRTC التي تحتاج TURN، والقياسات العامة تقول إن الاتصال المباشر يفشل في نحو محاولة من كل ثلاث، فقس نسبتك أنت من السجلات وgetStats().
  • كلمة مرور TURN الثابتة يراها كل مستخدم، فاستخدم البيانات المؤقتة عبر use-auth-secret ومدة قصيرة وساعة متزامنة.
  • TURN بلا قواعد يوصل إلى شبكتك الداخلية وإلى خدمة البيانات الوصفية في السحابة، فاحظر النطاقات الخاصة ب denied-peer-ip، وحدث coturn لأن ثغرات تجاوز هذه القواعد تتكرر، وأضف جدار حماية خارجه.
  • في المكالمات الجماعية يحمل سيرفر SFU العبء، ولأن عنوانه عام تقل الحاجة إلى TURN إلا لمن يحجب منفذ الوسائط.
دليلما هو NAT؟ شرح ترجمة العناوين وتوجيه المنافذلماذا تخرج كل أجهزة بيتك بعنوان عام واحد، وكيف يترجم ال Router العناوين، ولماذا يفشل توجيه المنافذ خلف CGNAT.دليلتثبيت Mattermost Team Edition مع PostgreSQL وترقيته بأمانمساحة محادثة لفريقك على سيرفرك مع Mattermost، تبقى فيها الرسائل والملفات كاملة، فلا يحذفها حد للرسائل ولا خطة مجانية.دليلتشغيل خادمك من إنترنت المنزل: كل طرق الوصول من الخارجكيف تصل إلى سيرفر في بيتك من الإنترنت حتى لو كنت خلف CGNAT، وما الطريقة المناسبة لك من بين ثماني طرق.دليلتقسيم الشبكة Network Segmentation بال VLAN وال VPCلماذا تصل كاميرا مخترقة إلى قاعدة بياناتك، وكيف تمنع ذلك بتقسيم الشبكة إلى مناطق لا يمر بينها إلا ما تسمح به.

سجل التحديثات

  • أكتوبر 2026: كتابة المقال، وبناء التجربة العملية على coturn 4.18.0 مع aiortc 1.15.0 وaioice 0.10.2 على Python 3.13، واختبار بيانات الدخول الثابتة والمؤقتة وTCP وTLS وقواعد denied-peer-ip.
Could not load content
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.