> ## Content Index
> Fetch the complete content index at: https://arabroot.io/llms.txt
> Use this file to discover other available public pages before exploring further.

# مكالمات WebRTC خلف NAT: شرح STUN وTURN وICE
- URL: https://arabroot.io/articles/مكالمات-webrtc-خلف-nat-شرح-ice-و-stun-و-turn/
- Published: 2026-10-08T11:00:00.000Z
- Updated: 2026-10-09T13:02:19.000Z
- Description: لماذا تتصل المكالمة ولا يصل الصوت، وكيف تحل STUN وTURN المشكلة دون أن تفتح باباً إلى شبكتك الداخلية.
- Author: فريق عرب رووت
- Tags: الأدلة التقنية, الشبكات والبنية التحتية, الشبكات والوصول, الاستضافة الذاتية

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

وقد شرحنا في [شرح NAT للمبتدئين](https://arabroot.io/articles/%D9%85%D8%A7-%D9%87%D9%88-nat-%D8%B4%D8%B1%D8%AD-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/) أنواع NAT وCGNAT وتوجيه المنافذ، ومررنا هناك على [STUN وHole Punching وTURN](https://arabroot.io/articles/%D9%85%D8%A7-%D9%87%D9%88-nat-%D8%B4%D8%B1%D8%AD-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#hole-punching) مروراً سريعاً، وفي [دليل Mattermost](https://arabroot.io/articles/%D8%AA%D8%AB%D8%A8%D9%8A%D8%AA-mattermost-%D9%88%D8%AA%D8%B1%D9%82%D9%8A%D8%AA%D9%87/#calls) فتحنا المنفذ 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](https://arabroot.io/articles/%D9%85%D8%A7-%D9%87%D9%88-nat-%D8%B4%D8%B1%D8%AD-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#nat-types)، أو خلف [CGNAT](https://arabroot.io/articles/%D9%85%D8%A7-%D9%87%D9%88-nat-%D8%B4%D8%B1%D8%AD-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#cgnat) عند المزود يتصرف بهذه الطريقة، فالمنفذ الذي عرفه الطرف عن نفسه ليس هو المنفذ الذي سوف يفتح للطرف الآخر، فتضيع الحزم.

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

![مخطط من جزأين: في الأعلى جهازان كل منهما خلف NAT يحافظ على المنفذ نفسه فيمر الصوت مباشرة بينهما عبر UDP، وفي الأسفل جهاز يحاول الوصول إلى هاتف خلف CGNAT أو NAT من نوع Symmetric فيفشل لأن المنفذ يتغير، وإلى حاسوب خلف جدار حماية شركة يمنع UDP ولا يسمح إلا بالمنفذ 443 فتسقط الحزم](https://arabroot.io/content/images/2026/10/webrtc-turn-01-direct-vs-blocked.webp)

الطريق المباشر ينجح بين أغلب الشبكات المنزلية، ويفشل خلف بعض أنواع NAT وخلف جدران الحماية التي تمنع UDP

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

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

في كل مكالمة WebRTC طريقان منفصلان، والخلط بينهما هو أكثر ما يربك من يشخص مشكلة صوت. الطريق الأول هو قناة التنسيق Signaling، وهي رسائل نصية يتبادلها الطرفان عبر سيرفر التطبيق ليتفقا على المكالمة: من يتصل بمن، وما الصيغ التي يدعمها كل منهما، وما عناوينه المحتملة. ولاحظ أن WebRTC لا يحدد كيف تنقل هذه الرسائل، فقد ذكر [RFC 9429](https://www.rfc-editor.org/rfc/rfc9429?ref=arabroot.io) الذي يعرف واجهة JSEP أن آلية التنسيق متروكة للتطبيق، فيستخدم Mattermost اتصال WebSocket على المنفذ 443، ويستخدم غيره طلبات HTTP عادية. أما الطريق الثاني فهو طريق الوسائط Media، وفيه يذهب الصوت والصورة والبيانات من جهاز إلى جهاز عبر UDP في الغالب، مشفرة دائماً بال DTLS-SRTP الذي يفرضه [RFC 8827](https://www.rfc-editor.org/rfc/rfc8827?ref=arabroot.io)، والصورة التالية تبين الطريقين:

![سيرفر التطبيق في الأعلى يمرر عرض SDP مع العناوين المرشحة من الطرف A إلى الطرف B عبر HTTPS أو WebSocket على المنفذ 443، ثم يمرر الجواب في الاتجاه المعاكس، وفي الأسفل يختبر الطرفان أزواج العناوين مباشرة بطلبات STUN Binding ثم يمر الصوت والصورة بينهما عبر UDP مشفراً بال DTLS-SRTP](https://arabroot.io/content/images/2026/10/webrtc-turn-02-signaling-media.webp)

قناة التنسيق تمر عبر سيرفرك، والصوت يأخذ الطريق الذي ينجح فيه ICE

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

الرسائل التي تمر في قناة التنسيق مكتوبة بصيغة وصف الجلسة Session Description Protocol، واختصاراً SDP، التي يعرفها [RFC 8866](https://www.rfc-editor.org/rfc/rfc8866?ref=arabroot.io)، وتتبع نموذج العرض والجواب Offer/Answer الذي يعرفه [RFC 3264](https://www.rfc-editor.org/rfc/rfc3264?ref=arabroot.io): يكتب المتصل عرضاً Offer فيه قسم لكل نوع من الوسائط، صوت أو فيديو أو قناة بيانات، ومع كل قسم الصيغ Codecs التي يدعمها وبصمة شهادة DTLS واسم مستخدم وكلمة مرور قصيرة لفحوص ICE، ثم أسطر تبدأ ب `a=candidate:` فيها عناوينه المحتملة كما يعرفها [RFC 8839](https://www.rfc-editor.org/rfc/rfc8839?ref=arabroot.io)، فيرد الطرف الآخر بجواب Answer يختار فيه من هذه الصيغ ويضيف عناوينه. وهذه الأسطر هي التي سوف نقرؤها في [التجربة](#lab-client)، وهي أهم ما في ال SDP لمن يشخص مشكلة اتصال.

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

الإجراء الذي يجمع العناوين ويختبرها ويختار منها اسمه Interactive Connectivity Establishment، واختصاراً ICE، ويعرفه [RFC 8445](https://www.rfc-editor.org/rfc/rfc8445?ref=arabroot.io)، و[RFC 8835](https://www.rfc-editor.org/rfc/rfc8835?ref=arabroot.io) يلزم كل متصفح يدعم 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](https://arabroot.io/content/images/2026/10/webrtc-turn-03-candidate-types.webp)

ثلاثة عناوين للجهاز نفسه، على الجهاز وعلى ال NAT وعلى سيرفر TURN

- **العنوان المحلي host:** عنوان الجهاز نفسه على شبكته، مثل `192.168.1.20:50000`، وهو يكفي إذا كان الطرفان على الشبكة نفسها.
- **العنوان العام كما يراه السيرفر Server Reflexive، واختصاراً srflx:** العنوان العام الذي يظهر به الجهاز بعد ترجمة NAT، ويعرفه الجهاز عندما يسأل سيرفر STUN، وهو العنوان الذي يستخدمه Hole Punching.
- **عنوان ال Relay:** عنوان على سيرفر TURN نفسه حجزه الجهاز لنفسه، وكل ما يصل إلى هذا العنوان يمرره السيرفر إلى الجهاز، وهو آخر الحلول ولكنه الذي ينجح حتى خلف أصعب الشبكات.

ويعطي ICE كل نوع أولوية، و[القسم 5.1.2.2](https://www.rfc-editor.org/rfc/rfc8445?ref=arabroot.io#section-5.1.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](https://www.rfc-editor.org/rfc/rfc8838.html?ref=arabroot.io)، فتبدأ المكالمة أسرع.

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

[Session Traversal Utilities for NAT](https://www.rfc-editor.org/rfc/rfc8489?ref=arabroot.io)، واختصاراً STUN، هو أبسط الأجزاء: يرسل الجهاز طلب Binding إلى سيرفر STUN على المنفذ 3478 بال UDP أو TCP، أو على المنفذ 5349 عبر TLS، فيرد السيرفر بالخاصية XOR-MAPPED-ADDRESS وفيها العنوان والمنفذ اللذان رأى الطلب قادماً منهما، وهذا هو العنوان من نوع srflx. ولأن السيرفر يرد على رسالة صغيرة ثم ينسى الطلب، فإن تشغيله لا يكلف شيئاً يذكر، ولا يمر عبره أي صوت، ونفس البروتوكول هو الذي تستخدمه فحوص ICE بين الطرفين كما رأينا أعلاه. والمعلومة المهمة هنا أن STUN يخبرك بعنوانك فقط، ولا يضمن أن الطرف الآخر يستطيع الوصول إليه، فإذا كان NAT من النوع Symmetric فالعنوان الذي يعيده STUN صحيح بالنسبة لسيرفر STUN وحده.

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

[Traversal Using Relays around NAT](https://www.rfc-editor.org/rfc/rfc8656?ref=arabroot.io)، واختصاراً TURN، يحل ما لا يحله STUN: بدلاً من أن يعرف الجهاز عنوانه العام، يحجز عنواناً عاماً على سيرفر وسيط، فيرسل كل شيء إلى السيرفر، ويرسله السيرفر باسمه إلى الطرف الآخر، والصورة التالية تبين الخطوات:

![حاسوب في مكتب خلف جدار حماية لا يسمح إلا بالمنفذ 443 يتصل بسيرفر coturn عبر TLS، والسيرفر يستمع على المنافذ 3478 و5349 و443، ويحجز له عنوان Relay هو 198.51.100.5 على المنفذ 49170، ويرسل منه الحركة عبر UDP إلى هاتف خلف CGNAT، ويعيد ردوده إلى الحاسوب، مع أربع خطوات مرقمة وملاحظة أن كل حزمة تدخل السيرفر وتخرج منه](https://arabroot.io/content/images/2026/10/webrtc-turn-04-turn-relay.webp)

الطرفان يتحدثان مع عنوان ال 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 ما لم يراسله الجهاز أولاً، كما يشرح [قسم الأمان](https://www.rfc-editor.org/rfc/rfc8656?ref=arabroot.io#section-21) في المعيار.
3. **إرسال البيانات:** يرسل الجهاز كل حزمة داخل رسالة Send، ويضيف السيرفر لكل حزمة واردة رسالة Data، وفي كل منهما ترويسة STUN كاملة، لذلك يوفر المعيار القنوات Channels، حيث يربط الجهاز رقم قناة من 0x4000 إلى 0x4FFF بعنوان الطرف الآخر بطلب ChannelBind، فتصبح الترويسة أربعة بايتات فقط، والقناة عمرها 10 دقائق وتجدد.

ولاحظ أن الطرف الآخر لا يحتاج أن يعرف أن هناك TURN، فهو يرى عنوان ال Relay كأي عنوان مرشح آخر من نوع relay، ويرسل إليه حزمه العادية، ولهذا يكفي في كثير من الحالات أن يستخدم طرف واحد فقط ال Relay، كما سوف ترى في [التجربة](#lab-block-direct).

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

الاتصال بين الجهاز وسيرفر TURN يمكن أن يكون عبر UDP، وهو الأفضل للصوت لأنه لا يعيد إرسال ما ضاع، أو عبر TCP، أو عبر TLS فوق TCP، وعنوان السيرفر يكتب في إعداد التطبيق بصيغة `turn:` أو `turns:` التي يعرفها [RFC 7065](https://www.rfc-editor.org/rfc/rfc7065?ref=arabroot.io)، مع `?transport=udp` أو `?transport=tcp`. و[RFC 8835](https://www.rfc-editor.org/rfc/rfc8835?ref=arabroot.io) يلزم متصفحات WebRTC بدعم TURN عبر TCP وعبر TLS، والسبب الذي يذكره صراحة هو جدران الحماية التي تمنع كل حركة UDP. ولذلك يشغل كثير من المشغلين TURN عبر TLS على المنفذ 443، لأن جدار الحماية في الشركة يرى عندها اتصال TLS عادياً على منفذ HTTPS فيسمح به، و[RFC 8828](https://www.rfc-editor.org/rfc/rfc8828?ref=arabroot.io) الذي يحدد ما يكشفه المتصفح من عناوين يوصي التطبيقات صراحة بأن يدعم سيرفر TURN عندها UDP وTCP معاً، لأن بعض أوضاع الخصوصية في المتصفح لا تسمح إلا بالمرور عبر Proxy أو عبر TCP. ولاحظ أن الصوت فوق TCP يتأثر بفقد الحزم أكثر، لأن TCP يوقف كل ما بعد الحزمة الضائعة حتى تصل، فهذا الطريق للنجاة من جدار الحماية وليس للجودة.

ويجدر الانتباه إلى أن الطرف الثاني من ال Relay، أي بين سيرفر TURN والطرف الآخر، يبقى UDP في WebRTC حتى عندما يتصل الجهاز بالسيرفر عبر TCP، أما ال Relay الذي يكون فيه هذا الطرف أيضاً TCP فيعرفه [RFC 6062](https://www.rfc-editor.org/rfc/rfc6062?ref=arabroot.io)، و[RFC 8835](https://www.rfc-editor.org/rfc/rfc8835?ref=arabroot.io) يجعل دعمه اختيارياً ويذكر أنه لا يضيف فائدة تذكر.

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

هنا الفرق الكبير بين STUN وTURN. فسيرفر STUN يرد على رسالة صغيرة في بداية المكالمة، أما سيرفر TURN فكل حزمة صوت وصورة تدخله مرة وتخرج منه مرة، فإذا كانت المكالمة تستهلك X ميجابت في الثانية في كل اتجاه، فإن السيرفر يستقبل 2X ويرسل 2X طوال المكالمة، وإذا استخدم الطرفان معاً Relay على السيرفر نفسه، كما في التجربة، مرت الحزمة عبره مرتين فتضاعف الرقم. لذلك اسأل نفسك قبل تشغيل TURN: كم مكالمة متزامنة تتوقع أن تمر عبره، وما سعر ال Bandwidth الخارج عند مزود السيرفر؟ وقد شرحنا في [التكاليف الخفية في السحابة](https://arabroot.io/articles/%D8%A7%D9%84%D8%AA%D9%83%D8%A7%D9%84%D9%8A%D9%81-%D8%A7%D9%84%D8%AE%D9%81%D9%8A%D8%A9-%D9%81%D9%8A-%D8%A7%D9%84%D8%B3%D8%AD%D8%A7%D8%A8%D8%A9-egress-%D9%88%D8%A7%D9%84%D8%AA%D8%AE%D8%B2%D9%8A%D9%86/#egress) لماذا تكون الحركة الخارجة Egress من أكبر بنود الفاتورة. وفي المقابل فإن المنافذ المحجوزة تحدد عدد الجلسات المتزامنة، لأن كل Allocation يأخذ منفذاً من النطاق، فالنطاق من 49160 إلى 49199 في التجربة يكفي أربعين Allocation فقط.

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

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

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

- [دراسة Bryan Ford وزملائه](https://bford.info/pub/net/p2pnat/?ref=arabroot.io) المنشورة في [مؤتمر USENIX عام 2005](https://www.usenix.org/conference/2005-usenix-annual-technical-conference/peer-peer-communication-across-network-address?ref=arabroot.io) جمعت 380 نتيجة لأجهزة NAT من 68 شركة، ووجدت أن 82% منها تسمح ب Hole Punching عبر UDP، و64% من 286 نتيجة تسمح به عبر TCP، وهي أرقام قديمة لأجهزة منزلية قبل انتشار CGNAT.
- [دراسة Trautwein وزملائه](https://arxiv.org/abs/2604.12484?ref=arabroot.io) المقبولة في مؤتمر IMC عام 2026 قاست أكثر من 4.4 مليون محاولة اتصال مباشر من أكثر من 85 ألف شبكة في 167 دولة داخل شبكة IPFS، ووجدت أن Hole Punching ينجح في نحو 70% ± 7.1% من المحاولات بعد أن تنجح المراحل التي تسبقه، وهذا بروتوكول libp2p وليس WebRTC، ولكنه يعطيك فكرة حديثة: قرابة محاولة من كل ثلاث تحتاج طريقاً بديلاً.

والرقم الذي يهمك فعلاً هو رقم مستخدميك أنت، وتعرفه من سجلات coturn ومن نوع الزوج المختار في كل مكالمة كما في قسم [كيف تعرف أن المكالمة مرت عبر TURN](#browser-check). وإذا كان التطبيق نفسه يستخدم سيرفراً للوسائط بعنوان عام، كما في Mattermost Calls، فلا تحتاج TURN إلا لمن لا يستطيع الوصول إلى منفذ الوسائط، و[دليل نشر Calls](https://docs.mattermost.com/deployment-guide/calls/calls-deployment-guide?ref=arabroot.io) يقول ذلك صراحة: لا تنشر TURN إذا كان المستخدمون يصلون إلى المنفذ 8443 بال UDP أو TCP، وإذا احتجته فهو يوصي ب coturn.

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

سيرفر TURN بلا مصادقة هو Relay مفتوح Open Relay يستخدمه أي أحد على الإنترنت لتمرير حركته وإخفاء عنوانه، والمعيار نفسه يذكر في قسم [إخفاء مصدر الحركة الضارة](https://www.rfc-editor.org/rfc/rfc8656?ref=arabroot.io#section-21) أن سيرفرات TURN جذابة للمهاجمين لهذا السبب، لذلك يطلب coturn بيانات دخول، والسؤال هو أين تضعها.

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

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

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

الحل الذي تستخدمه أغلب الخدمات هو أن يصدر سيرفر التطبيق لكل مستخدم بيانات دخول تنتهي بعد مدة، وهي طريقة وصفتها [مسودة TURN REST API](https://datatracker.ietf.org/doc/html/draft-uberti-behave-turn-rest-00?ref=arabroot.io) من Google، وهي مسودة لم تصبح معياراً ولكن coturn يطبقها بالخيار `use-auth-secret` كما يشرح [ملف الإعداد المرجعي](https://github.com/coturn/coturn/blob/4.18.0/examples/etc/turnserver.conf?ref=arabroot.io)، والفكرة كالتالي:

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

والنتيجة أن من ينسخ بيانات الدخول من المتصفح يستخدمها حتى تنتهي فقط، وأن معرف المستخدم يظهر في سجلات coturn فتعرف من استهلك ال Bandwidth. وتذكر أن نقطة إصدار هذه البيانات في تطبيقك يجب أن تكون خلف تسجيل الدخول، وأن تحدد معدل الطلبات عليها كما شرحنا في [الحد من معدل الطلبات Rate Limiting](https://arabroot.io/articles/%D8%AD%D9%85%D8%A7%D9%8A%D8%A9-%D8%AA%D8%B7%D8%A8%D9%8A%D9%82%D8%A7%D8%AA-%D8%A7%D9%84%D9%88%D9%8A%D8%A8-%D8%B9%D9%84%D9%89-%D9%85%D8%B3%D8%AA%D9%88%D9%89-http/#rate-limiting)، وإلا أصبحت مصدراً مفتوحاً لبيانات دخول صالحة. وهناك طريقة ثالثة تعتمد على توكن OAuth يعرفها [RFC 7635](https://www.rfc-editor.org/rfc/rfc7635?ref=arabroot.io)، ولكن دعمها في المكتبات والمتصفحات أقل، لذلك لن نتناولها هنا.

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

هذا أخطر ما في تشغيل TURN، وأقل ما يذكر في الأدلة السريعة. فسيرفر TURN يرسل الحزم إلى أي عنوان يطلبه المستخدم بعد المصادقة، والسيرفر نفسه موجود داخل شبكتك أو داخل شبكة ال VPC عند مزود السحابة، فإذا طلب المستخدم الإذن لعنوان داخلي مثل `10.0.0.5`، أو لعنوان خدمة البيانات الوصفية Metadata عند المزود `169.254.169.254`، أو لعنوان `127.0.0.1` على السيرفر نفسه، فإن السيرفر يرسل حزمه إلى هناك، أي أن من يملك بيانات دخول صالحة، وقد عرفنا أنها في متصفح كل مستخدم، يصل إلى ما لا يصل إليه الإنترنت، وهي فكرة [SSRF](https://arabroot.io/articles/%D8%AB%D8%BA%D8%B1%D8%A7%D8%AA-%D8%A7%D9%84%D9%88%D9%8A%D8%A8-%D8%A8%D8%A7%D9%84%D8%AA%D8%AC%D8%B1%D8%A8%D8%A9-sql-injection-%D9%88-xss-%D9%88-ssrf/#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 على السيرفر نفسه، بينما تمر حركة المكالمة إلى طرف حقيقي بعنوان عام](https://arabroot.io/content/images/2026/10/webrtc-turn-05-peer-restrictions.webp)

القاعدة تغلق الطريق إلى العناوين الداخلية وتترك الطريق إلى الأطراف الحقيقية

📌

في فبراير 2018 وجد فريق Enable Security أن سيرفرات TURN التي تستخدمها Slack للمكالمات تمرر حركة TCP وUDP إلى السيرفر نفسه وإلى عناوين داخلية في شبكة Slack على AWS، ومنها خدمة البيانات الوصفية على `169.254.169.254` التي تعطي مفاتيح IAM مؤقتة، فأبلغوا Slack في أبريل وأصلحت السيرفرات في مايو 2018، ونشر [التقرير الكامل](https://www.enablesecurity.com/blog/slack-webrtc-turn-compromise-and-bug-bounty/?ref=arabroot.io) في مارس 2020 وفيه قائمة العناوين التي يوصون بحظرها في coturn بالخيار `denied-peer-ip`، ويمكنك قراءة [البلاغ نفسه على HackerOne](https://hackerone.com/reports/333419?ref=arabroot.io).

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

coturn يمنع العناوين `127.x.x.x` و`::1` افتراضياً ما لم تفعل `allow-loopback-peers`، ولكنه لا يمنع العناوين الخاصة الأخرى، والقرار لك بالخيارين `denied-peer-ip` و`allowed-peer-ip`، وفي [ملف الإعداد المرجعي](https://github.com/coturn/coturn/blob/4.18.0/examples/etc/turnserver.conf?ref=arabroot.io) قاعدة مهمة: إذا كان العنوان في نطاق محظور وفي نطاق مسموح معاً فهو مسموح، وهذا يفيد عندما تحظر نطاقاً كاملاً وتستثني منه عنواناً أو اثنين. والنطاقات التي يجب حظرها على الأقل هي العناوين الخاصة `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، كما سوف ترى في [ملف التجربة](#lab-rest).

ولا تعتمد على هذه القائمة وحدها، والسبب أن ثغرات تجاوز هذه القواعد تتكرر في سجل coturn: فقد كان العنوان `0.0.0.0` وال IPv6 `[::1]` و`[::]` يوصل إلى واجهة loopback رغم المنع الافتراضي حتى الإصدار 4.5.2 في [CVE-2020-26262](https://github.com/coturn/coturn/security/advisories/GHSA-6g6j-r9rf-cm7p?ref=arabroot.io)، ثم ظهر في 2026 أن صيغة IPv4 داخل IPv6 مثل `::ffff:127.0.0.1` تتجاوز `denied-peer-ip` في [CVE-2026-27624](https://github.com/coturn/coturn/security/advisories/GHSA-j8mm-mpf8-gvjg?ref=arabroot.io)، وتتجاوز منع loopback الافتراضي في [CVE-2026-53450](https://github.com/coturn/coturn/security/advisories/GHSA-w4hf-cr3w-6h79?ref=arabroot.io) المصلحة في 4.13.0، ثم ظهر تجاوز آخر عبر صيغ IPv6 تترجم إلى IPv4 في [CVE-2026-73212](https://github.com/coturn/coturn/security/advisories/GHSA-2x4g-wx24-48m4?ref=arabroot.io) المصلحة في 4.13.1\. لذلك تابع [صفحة التنبيهات الأمنية](https://github.com/coturn/coturn/security/advisories?ref=arabroot.io) وحدث coturn باستمرار، وأضف طبقة ثانية خارجه: جدار حماية على السيرفر أو مجموعة أمان Security Group عند المزود تمنع السيرفر نفسه من الاتصال بشبكتك الداخلية، وضعه في شبكة منفصلة كما شرحنا في [تقسيم الشبكة](https://arabroot.io/articles/%D8%AA%D9%82%D8%B3%D9%8A%D9%85-%D8%A7%D9%84%D8%B4%D8%A8%D9%83%D8%A9-network-segmentation-vlan-%D9%88-vpc/).

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

حتى مع البيانات المؤقتة قد يستخدم أحد بيانات صالحة لاستهلاك سيرفرك، فضع حدوداً من الخيارات التي يشرحها ملف الإعداد: `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](https://www.rfc-editor.org/rfc/rfc7667?ref=arabroot.io) يصف الأشكال الممكنة، وأشهرها ثلاثة:

![ثلاثة أجزاء لمكالمة بين أربعة أشخاص: في Mesh يتصل كل شخص بالثلاثة الآخرين فيرسل ثلاث نسخ ويستقبل ثلاثاً، وفي SFU يرسل كل شخص نسخة واحدة إلى السيرفر فيمررها إلى الآخرين دون معالجة، وفي MCU يرسل كل شخص نسخة واحدة ويستقبل نسخة واحدة مدموجة يصنعها السيرفر، مع جدول للإرسال والاستقبال وعمل السيرفر ومتى تحتاج TURN](https://arabroot.io/content/images/2026/10/webrtc-turn-06-topologies.webp)

كلما زاد عدد المشاركين انتقل العبء من أجهزة المستخدمين إلى السيرفر

- **Mesh:** يتصل كل شخص بكل شخص اتصالاً مباشراً، فلا يحتاج سيرفراً للوسائط، ولكن كل شخص يرسل نسخة من صوته وصورته لكل مشارك، فيمتلئ اتصاله وجهازه بسرعة، ولهذا يناسب شخصين أو ثلاثة، وفيه يحتاج كل زوج إلى طريق بين الطرفين، فتحتاج TURN بالمقدار الذي ناقشناه أعلاه.
- **SFU، أي Selective Forwarding Unit:** يرسل كل شخص نسخة واحدة إلى سيرفر يمررها إلى الآخرين كما هي دون فك الترميز، والمعيار يسميه Selective Forwarding Middlebox، وعليه يبنى Mattermost Calls الذي تصف خدمته [rtcd في توثيقها](https://github.com/mattermost/rtcd/blob/master/docs/implementation.md?ref=arabroot.io) بأنها 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`:

```bash
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 الأربعة](https://arabroot.io/articles/%D8%A7%D9%84%D8%B4%D9%87%D8%A7%D8%AF%D8%A7%D8%AA-%D8%A7%D9%84%D8%B1%D9%82%D9%85%D9%8A%D8%A9-tls-%D9%88-x509-%D9%88%D8%B5%D9%8A%D8%BA-pem-%D9%88-der/#lets-encrypt-files). بعد ذلك نكتب ملف الإعداد `turnserver-lt.conf`:

```ini
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` فقط حتى لا تكون التجربة مكشوفة على شبكتك:

```bash
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"
```

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

```bash
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:

```bash
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:

```bash
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
```

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

```bash
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 ترى ما حدث خطوة خطوة:

```bash
docker logs --since 15s lab-turn-coturn 2>&1 | grep "session 000000000000000001"
```

```bash
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](#allocations): `ALLOCATE` ثم `REFRESH` لتجديد العمر، ثم `CHANNEL_BIND` بعمر 600 ثانية وهي الدقائق العشر للقناة، و`CREATE_PERMISSION` بعمر 300 ثانية وهي الدقائق الخمس التي يحددها المعيار للإذن. ولاحظ أيضاً شيئاً أخطر: الطرف `10.77.0.30` سيرفر داخلي في شبكتنا، ومع ذلك وصلت إليه الرسائل عبر TURN دون أي اعتراض، وهذه هي المشكلة التي شرحناها في [قسم الأمان](#security)، وسوف نغلقها بعد قليل.

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

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

```bash
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 ثانية ثم تتوقف بهذا السطر:

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

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

```bash
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 محجوباً:

```bash
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
```

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

```bash
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:

```bash
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](https://github.com/aiortc/aiortc?ref=arabroot.io) الإصدار [1.15.0](https://pypi.org/project/aiortc/1.15.0/?ref=arabroot.io)، وهي تطبيق WebRTC كامل بلغة Python يعتمد على مكتبة [aioice](https://github.com/aiortc/aioice?ref=arabroot.io) لل ICE. ننشئ Container للعميل ونثبت المكتبة، ونعطيه الصلاحية `NET_ADMIN` لأننا سوف نحتاج جدار الحماية فيه بعد قليل:

```bash
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:

```python
# 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](https://www.w3.org/TR/webrtc/?ref=arabroot.io#dom-rtcicetransportpolicy)، أما aiortc فلا يوفر هذا الخيار في الإصدار 1.15.0، لذلك نمرر سياسة `TransportPolicy.RELAY` مباشرة إلى aioice الذي يدعمها، وهذا هو السطر الوحيد الذي يتجاوز الواجهة العامة للمكتبة، ومعه دالة `chosen` التي تقرأ الزوج المختار من داخل aioice.
- مع `--policy all` نضيف سيرفر STUN على العنوان نفسه، فيجمع كل طرف الأنواع الثلاثة، ومع `--policy relay` يجمع عنوان ال Relay فقط.
- `MAX_BUNDLE` يجعل الصوت وقناة البيانات يشتركان في اتصال واحد، وهذا ما تفعله المتصفحات افتراضياً، فتظهر قائمة واحدة من العناوين.
- مع `--secret` يحسب البرنامج بيانات الدخول المؤقتة بالطريقة التي شرحناها في [قسم السر المشترك](#rest-credentials)، وهذه الأسطر الثلاثة هي ما تضعه في سيرفر تطبيقك.

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

```bash
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
```

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

```bash
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:

```bash
docker exec lab-turn-client python /call.py --policy relay --user alice --password Lab-Pass-2026
```

```bash
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` فلا تتأثر، ثم نشغل المكالمة بالسياسة العادية:

```bash
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
```

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

```bash
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 طرف واحد، ويستخدمه الآخر كعنوان عادي.

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

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

```bash
openssl rand -hex 32
```

```bash
f21ccbe9f07c65b5691ff2ae5124b14e5a299873ec7603e8b4e93395c4a7aab4
```

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

```ini
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` هي النطاقات التي ذكرناها في [قسم الحظر](#denied-peer-ip)، مكتوبة بصيغة من عنوان إلى عنوان.
- السطر `allowed-peer-ip=10.77.0.10` خاص بالتجربة، وسوف نشرح سببه بعد قليل.

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

```bash
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 بعدها:

```bash
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"
```

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

```bash
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
```

```bash
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](#bandwidth).

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

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

```bash
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
```

```bash
2026-10-08T09:04:16.043+0000 INFO channel bind: error 403 ()
```

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

```bash
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:

```bash
docker exec lab-turn-client python /call.py --policy all --secret f21ccbe9f07c65b5691ff2ae5124b14e5a299873ec7603e8b4e93395c4a7aab4
```

```bash
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
```

```bash
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)
```

قارن هذه النتيجة بنتيجة [الحجب السابق](#lab-block-direct): هناك اختار 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 فقط:

```bash
sed '/^allowed-peer-ip/d' turnserver-rest.conf > turnserver-noallow.conf
```

```bash
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
```

```bash
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:

```bash
docker exec lab-turn-client python /call.py --policy relay --secret f21ccbe9f07c65b5691ff2ae5124b14e5a299873ec7603e8b4e93395c4a7aab4 --ttl -60
```

```bash
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
```

```bash
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.

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

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

```bash
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 الرسمية](https://github.com/coturn/coturn/blob/4.18.0/docker/coturn/README.md?ref=arabroot.io) يوصي بتشغيل coturn بالخيار `--network=host`، لأن Docker يتعامل بشكل سيئ مع نطاقات المنافذ الكبيرة، وقد شرحنا شبكة host وحدودها في [شبكات Docker](https://arabroot.io/articles/%D8%B4%D8%A8%D9%83%D8%A7%D8%AA-docker-%D9%88%D8%A3%D9%81%D8%B6%D9%84-%D8%A7%D9%84%D9%85%D9%85%D8%A7%D8%B1%D8%B3%D8%A7%D8%AA/#host-none).
- **العنوان العام:** إذا كان السيرفر خلف NAT عند المزود، كما في كثير من السحابات حيث يرى السيرفر عنواناً خاصاً، فأضف `external-ip=203.0.113.10/10.0.0.4` بالعنوان العام ثم الخاص، والسبب أن coturn يعيد هذا العنوان للعميل في XOR-RELAYED-ADDRESS، فإذا أعاد العنوان الخاص لم يصل إليه أحد، وهي المشكلة نفسها التي حلها ICE Host Override في [إعداد Mattermost Calls](https://arabroot.io/articles/%D8%AA%D8%AB%D8%A8%D9%8A%D8%AA-mattermost-%D9%88%D8%AA%D8%B1%D9%82%D9%8A%D8%AA%D9%87/#calls-setup).
- **المنفذ 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` الخاص بالتجربة، وأبق الحدود وقواعد الحظر، وراجع [قائمة الخيارات الكاملة](https://github.com/coturn/coturn/blob/4.18.0/README.turnserver?ref=arabroot.io) قبل أن تضيف شيئاً لا تعرفه.

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

```javascript
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](https://www.w3.org/TR/webrtc-stats/?ref=arabroot.io) يوفر عبر الدالة `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 من نطاق محظور كما في [التجربة](#lab-allowed)، والحل `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 إلا لمن يحجب منفذ الوسائط.

[![](https://arabroot.io/content/images/2026/10/nat-explained-cover-cover.webp)دليلما هو NAT؟ شرح ترجمة العناوين وتوجيه المنافذلماذا تخرج كل أجهزة بيتك بعنوان عام واحد، وكيف يترجم ال Router العناوين، ولماذا يفشل توجيه المنافذ خلف CGNAT.](https://arabroot.io/articles/%D9%85%D8%A7-%D9%87%D9%88-nat-%D8%B4%D8%B1%D8%AD-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/)[![](https://arabroot.io/content/images/2026/10/mattermost-cover-cover.webp)دليلتثبيت Mattermost Team Edition مع PostgreSQL وترقيته بأمانمساحة محادثة لفريقك على سيرفرك مع Mattermost، تبقى فيها الرسائل والملفات كاملة، فلا يحذفها حد للرسائل ولا خطة مجانية.](https://arabroot.io/articles/%D8%AA%D8%AB%D8%A8%D9%8A%D8%AA-mattermost-%D9%88%D8%AA%D8%B1%D9%82%D9%8A%D8%AA%D9%87/)[![](https://arabroot.io/content/images/2026/10/home-internet-cover-cover.webp)دليلتشغيل خادمك من إنترنت المنزل: كل طرق الوصول من الخارجكيف تصل إلى سيرفر في بيتك من الإنترنت حتى لو كنت خلف CGNAT، وما الطريقة المناسبة لك من بين ثماني طرق.](https://arabroot.io/articles/%D8%AA%D8%B4%D8%BA%D9%8A%D9%84-%D8%AE%D8%A7%D8%AF%D9%85%D9%83-%D9%85%D9%86-%D8%A5%D9%86%D8%AA%D8%B1%D9%86%D8%AA-%D8%A7%D9%84%D9%85%D9%86%D8%B2%D9%84/)[![](https://arabroot.io/content/images/2026/10/network-segmentation-cover-cover.webp)دليلتقسيم الشبكة Network Segmentation بال VLAN وال VPCلماذا تصل كاميرا مخترقة إلى قاعدة بياناتك، وكيف تمنع ذلك بتقسيم الشبكة إلى مناطق لا يمر بينها إلا ما تسمح به.](https://arabroot.io/articles/%D8%AA%D9%82%D8%B3%D9%8A%D9%85-%D8%A7%D9%84%D8%B4%D8%A8%D9%83%D8%A9-network-segmentation-vlan-%D9%88-vpc/)

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

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