في مصطلحات الاتصالات الهاتفية VoIP للمبتدئين رأينا أن المكالمة لها طريقان: رسائل SIP التي تجهزها، وصوت RTP الذي يمر على منافذ أخرى، وأن العنوان الذي يستقبل عليه كل هاتف الصوت مكتوب داخل الرسالة نفسها في SDP. وهذا التفصيل الصغير هو سبب أشهر شكوى في كل مكتب انتقل إلى VoIP: الهاتف يرن، والموظف يرد، والعميل يسمعه، أما الموظف فلا يسمع شيئاً، أو الهاتف يظهر مسجلاً في لوحة المقسم ولا يرن عندما يتصل به أحد.
وفي هذا المقال لن نشرح هذه المشكلات من بعيد، وإنما سوف نبنيها بأيدينا: مقسم Asterisk حقيقي، وهاتفان برمجيان، وراوتر يعمل بال NAT بينهما، ثم نتتبع كل مكالمة حزمة بحزمة، ونكسر الإعداد عمداً في كل مرة ونرى العرض الذي يراه المستخدم، ثم نصلحه بالخيار المناسب. وهذا المقال من مرحلة الاتصالات الهاتفية VoIP، وسوف نتناول فيه ما يلي:
- بناء التجربة في Docker: Asterisk وbaresip وtcpdump وراوتر NAT في Containers.
- المكالمة الأولى دون NAT، وكيف تقرأ رسائلها وحزم الصوت فيها.
- نقل الهاتف خلف NAT، ثم أربعة أعطال حقيقية: رفض التسجيل 403، والهاتف الذي لا يرن، والصوت في اتجاه واحد، والصوت المقطوع في الاتجاهين بسبب جدار الحماية.
- الهاتف المسجل الذي يتوقف عن الرنين بعد ثوان من السكوت، وكيف يحله الفحص الدوري.
- الإعداد النهائي، وما يجب أن تغيره قبل أن تنقل هذا إلى سيرفر حقيقي، وجدول سريع للتشخيص.
خريطة التجربة
نحتاج في التجربة شبكتين: شبكة تلعب دور الإنترنت فيها المقسم وهاتف Alice، وشبكة مكتب خاصة فيها هاتف Bob، وبينهما راوتر يعمل بال NAT كما يعمل أي راوتر منزلي أو مكتبي، أي يغير عنوان الحزم الخارجة إلى عنوانه هو، ويرفض أي اتصال جديد يأتي من الخارج. وقد شرحنا هذا السلوك في شرح NAT للمبتدئين. والصورة التالية تبين الخريطة:

ولاحظ أن كل شيء يعمل داخل شبكات Docker، فلا يوجد منفذ واحد مفتوح على جهازك، والشبكة التي تمثل الإنترنت أنشأناها بالخيار --internal حتى لا يكون لها طريق إلى خارج جهازك أصلاً. وإذا كنت جديداً على شبكات Docker فقد شرحناها في شبكات Docker وأفضل الممارسات.
تجهيز ال Image للتجربة
لا توجد Image رسمية من مشروع Asterisk على Docker Hub، لذلك سوف نبني Image واحدة صغيرة من Ubuntu 24.04 فيها كل الأدوات، ونثبت إصدار كل حزمة حتى تحصل على النتيجة نفسها: Asterisk مقسماً، وbaresip هاتفاً برمجياً يعمل من سطر الأوامر، وtcpdump لالتقاط الحزم، وsngrep لعرض المكالمات بشكل تفاعلي. قم بإنشاء مجلد للتجربة، وضع فيه الملف Dockerfile التالي:
FROM ubuntu:24.04
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
asterisk=1:20.6.0~dfsg+~cs6.13.40431414-2build5 \
baresip-core=1.0.0-4build14 \
sngrep=1.8.0-1build2 \
tcpdump=4.99.4-3ubuntu4.24.04.1 \
sox iproute2 iptables iputils-ping \
&& rm -rf /var/lib/apt/lists/*
COPY sipflow.sh /usr/local/bin/sipflow
COPY rtpcount.sh /usr/local/bin/rtpcount
RUN chmod +x /usr/local/bin/sipflow /usr/local/bin/rtpcountوالسكربتان sipflow.sh وrtpcount.sh أداتان صغيرتان كتبناهما لقراءة ملف الالتقاط pcap: الأولى تطبع سطراً لكل رسالة SIP فيه الوقت والمصدر والوجهة وأول سطر من الرسالة، والثانية تعد حزم UDP في كل اتجاه بعد استبعاد SIP، أي حزم الصوت RTP وتقاريرها RTCP:
cat > sipflow.sh <<'EOF'
#!/bin/sh
# Prints one line per SIP message in a pcap: time, source > destination, first line
tcpdump -r "$1" -n -A 'port 5060' 2>/dev/null | awk '
/ IP [0-9.]+ > [0-9.]+:/ { split($1,t,"."); src=$3; dst=$5; sub(":","",dst); want=1; next }
want && match($0, /(REGISTER|INVITE|ACK|BYE|CANCEL|OPTIONS) sip:[^ ]+ SIP\/2\.0|SIP\/2\.0 [0-9][0-9][0-9] [A-Za-z ]+/) {
printf "%s %-18s > %-18s %s\n", t[1], src, dst, substr($0, RSTART, RLENGTH); want=0 }'
EOF
cat > rtpcount.sh <<'EOF'
#!/bin/sh
# Counts packets per UDP flow (RTP and RTCP) in a pcap, SIP excluded
tcpdump -r "$1" -n 'udp and not port 5060' 2>/dev/null | awk '{sub(":$","",$5); print $3, $4, $5}' | sort | uniq -c | sort -rn
EOF
docker build -t lab-voip-tools:1 .
docker run --rm lab-voip-tools:1 asterisk -Vوالمخرج سوف يكون كما يلي:
Asterisk 20.6.0~dfsg+~cs6.13.40431414-2build5إعداد Asterisk: تحويلتان وخطة اتصال
Asterisk يقرأ إعداداته من ملفات نصية في /etc/asterisk، وسوف نستبدل منها أربعة ملفات فقط. والملف الأهم هو asterisk/pjsip.conf الذي يعرف التحويلات عبر وحدة PJSIP، وهي الوحدة الحالية للتعامل مع SIP في Asterisk، وقد بدأنا عمداً بإعداد يفترض أن كل الهواتف في الشبكة نفسها مع المقسم، وأطفأنا فيه خيارات NAT الثلاثة صراحة حتى نرى أثر كل منها:
[global]
type=global
user_agent=ArabRoot-Lab-PBX
[transport-udp]
type=transport
protocol=udp
bind=0.0.0.0:5060
; قالب مشترك للتحويلات الداخلية Extensions
[phone](!)
type=endpoint
context=office
disallow=all
allow=ulaw,alaw
direct_media=no
rtp_symmetric=no
force_rport=no
rewrite_contact=no
[1001](phone)
auth=1001
aors=1001
callerid="Alice" <1001>
[1001]
type=auth
auth_type=userpass
username=1001
password=Lab-1001-pass
[1001]
type=aor
max_contacts=1
[1002](phone)
auth=1002
aors=1002
callerid="Bob" <1002>
[1002]
type=auth
auth_type=userpass
username=1002
password=Lab-1002-pass
[1002]
type=aor
max_contacts=1في الإعداد أعلاه لاحظ التالي:
- كل تحويلة في PJSIP تتكون من ثلاثة أجزاء بالاسم نفسه:
endpointيصف سلوكها، وauthيحمل اسم المستخدم وكلمة المرور، وaorوهو اختصار Address of Record، ويحفظ العنوان الذي سجل منه الهاتف آخر مرة. [phone](!)قالب Template لا يصبح تحويلة بنفسه، وكل تحويلة ترث منه بكتابة(phone)بعد اسمها، فتغير خيار NAT في مكان واحد.direct_media=noيجبر الصوت على المرور عبر المقسم، والقيمة الافتراضيةyesكما في المساعدة المدمجة في Asterisk، وسوف نحتاج هذا في كل الخطوات حتى نرى الصوت في الالتقاط.- القيم الافتراضية للخيارات الثلاثة الأخرى في هذا الإصدار هي
rtp_symmetric=noوforce_rport=yesوrewrite_contact=no، وقد جعلناforce_rportأيضاًnoحتى نبدأ من إعداد لا يعرف شيئاً عن NAT. - كلمات المرور هنا للتجربة فقط، وفي المقسم الحقيقي يجب أن تكون طويلة وعشوائية، لأن التحويلة المكشوفة تعني مكالمات على حسابك.
وبقية الملفات قصيرة: خطة الاتصال في asterisk/extensions.conf التي شرحناها في مقال المصطلحات، ونطاق صغير لمنافذ RTP في asterisk/rtp.conf حتى يسهل فتحه وتتبعه، وإعداد السجلات في asterisk/logger.conf:
cat > asterisk/extensions.conf <<'EOF'
[office]
exten => _10XX,1,NoOp(Call from ${CALLERID(num)} to ${EXTEN})
same => n,Dial(PJSIP/${EXTEN},20)
same => n,Hangup()
EOF
cat > asterisk/rtp.conf <<'EOF'
[general]
rtpstart=10000
rtpend=10099
EOF
cat > asterisk/logger.conf <<'EOF'
[general]
[logfiles]
console => notice,warning,error,verbose
EOFالآن نقوم بإنشاء الشبكتين وتشغيل المقسم:
docker network create --internal --subnet 10.77.50.0/24 lab-voip-wan
docker network create --subnet 192.168.77.0/24 lab-voip-lan
docker run -d --name lab-voip-pbx --network lab-voip-wan --ip 10.77.50.10 \
--cap-add NET_ADMIN --cap-add NET_RAW \
-v "$PWD/asterisk/pjsip.conf:/etc/asterisk/pjsip.conf" \
-v "$PWD/asterisk/extensions.conf:/etc/asterisk/extensions.conf" \
-v "$PWD/asterisk/rtp.conf:/etc/asterisk/rtp.conf" \
-v "$PWD/asterisk/logger.conf:/etc/asterisk/logger.conf" \
lab-voip-tools:1 asterisk -f
docker exec lab-voip-pbx asterisk -rx 'pjsip show endpoints'والصلاحيتان NET_ADMIN وNET_RAW نحتاجهما لاحقاً لتشغيل tcpdump وتعديل جدار الحماية داخل ال Container، والمخرج سوف يكون كما يلي، ولاحظ أن التحويلتين Unavailable لأن أي هاتف لم يسجل بعد:
Endpoint: 1001/1001 Unavailable 0 of inf
InAuth: 1001/1001
Aor: 1001 1
Endpoint: 1002/1002 Unavailable 0 of inf
InAuth: 1002/1002
Aor: 1002 1
Objects found: 2الهاتفان: baresip في Container
baresip هاتف SIP برمجي صغير مرخص بترخيص BSD كما في ملف الترخيص، ويعمل دون واجهة رسومية، فيناسب التجربة. ولأنه لا توجد سماعة ولا ميكروفون في ال Container، سوف نجعل مصدر الصوت ملفاً فيه نغمة ثابتة، ونجعل الهاتف يحفظ ما يسمعه في ملف، فإذا كان الملف فارغاً فهذا يعني أن الهاتف لم يسمع شيئاً. وهذه هي الأوامر:
mkdir -p alice bob sounds rec-alice rec-bob
docker run --rm -v "$PWD/sounds:/sounds" lab-voip-tools:1 \
sox -n -r 8000 -c 1 -b 16 /sounds/tone.wav synth 30 sine 440 vol 0.5
for p in alice:1001 bob:1002; do n=${p%%:*}; ext=${p#*:}
cat > $n/config <<EOF
poll_method epoll
sip_listen 0.0.0.0:5060
audio_source aufile,/sounds/tone.wav
audio_player alsa,null
audio_alert alsa,null
rtp_ports 20000-20019
module_path /usr/lib/baresip/modules
module g711.so
module aufile.so
module menu.so
module_app account.so
module alsa.so
module sndfile.so
snd_path /rec
EOF
echo "<sip:[email protected]>;auth_pass=Lab-$ext-pass;answermode=auto" > $n/accounts
done
echo "module stdio.so" >> alice/configفي الإعداد أعلاه لاحظ التالي:
audio_source aufileيرسل النغمة من الملف بدل الميكروفون، وalsa,nullمشغل صوت وهمي لا يخرج شيئاً.- الوحدة
sndfileتحفظ ما يرسله الهاتف وما يستقبله في ملفين في/rec، ومنها نعرف هل وصل الصوت فعلاً. answermode=autoيجعل الهاتف يرد تلقائياً، لأنه لا يوجد أحد يضغط زر الرد.- هاتف Alice وحده فيه الوحدة
stdioحتى نرسل له الأوامر من سطر الأوامر، مثل/dial 1002، وحتى يطبع سطر الإحصاءات الذي سنحتاجه.
نبدأ ب Bob على شبكة الإنترنت نفسها مع المقسم، أي دون NAT، ثم نسأل المقسم أين يجده:
docker run -d --name lab-voip-bob --network lab-voip-wan --ip 10.77.50.22 \
--cap-add NET_ADMIN --cap-add NET_RAW \
-v "$PWD/bob:/conf" -v "$PWD/sounds:/sounds:ro" -v "$PWD/rec-bob:/rec" \
lab-voip-tools:1 sh -c 'ip route replace default via 192.168.77.254 2>/dev/null; exec baresip -f /conf'
docker exec lab-voip-pbx asterisk -rx 'pjsip show contacts'والمخرج سوف يكون كما يلي:
Contact: 1002/sip:[email protected]:5060 74f845b4f3 NonQual nan
Objects found: 1والأمر ip route replace في تشغيل Bob لا يفعل شيئاً الآن، وسوف نحتاجه عندما ننقله إلى شبكة المكتب. والسطر أعلاه يعني أن المقسم حفظ للتحويلة 1002 العنوان الذي أعلنه الهاتف في الترويسة Contact عند التسجيل، وNonQual تعني أن المقسم لا يفحص هذا الهاتف بشكل دوري، وسوف نعود لهذه الكلمة في آخر التجربة.
المكالمة الأولى دون NAT
لكل مكالمة سوف نقوم بثلاثة أشياء: نشغل tcpdump على المقسم، ثم نشغل هاتف Alice ونرسل له أمر الاتصال، ثم نوقف الالتقاط ونقرأه. ولأننا سوف نكرر ذلك في كل خطوة، نضعه في السكربت call.sh:
cat > call.sh <<'EOF'
#!/bin/sh
# usage: ./call.sh <name> <seconds in call>
docker exec -d lab-voip-pbx tcpdump -i eth0 -n -s0 -w /tmp/$1.pcap udp
sleep 1
(sleep 3; echo "/dial 1002"; sleep $2; echo "/hangup"; sleep 2; echo "q") | \
docker run -i --rm --name lab-voip-alice --network lab-voip-wan --ip 10.77.50.21 \
-v "$PWD/alice:/conf" -v "$PWD/sounds:/sounds:ro" -v "$PWD/rec-alice:/rec" \
lab-voip-tools:1 baresip -f /conf 2>&1 | tr '\r' '\n' > $1-alice.log
sleep 1; docker exec lab-voip-pbx pkill tcpdump; sleep 1
docker exec lab-voip-pbx sipflow /tmp/$1.pcap
docker exec lab-voip-pbx rtpcount /tmp/$1.pcap
grep -a 'audio=' $1-alice.log | tail -1
EOF
chmod +x call.sh
./call.sh c1 10والمخرج سوف يكون كما يلي، بعد أن حذفنا منه أسطر التسجيل في البداية والنهاية:
19:13:44 10.77.50.21.5060 > 10.77.50.10.5060 INVITE sip:[email protected] SIP/2.0
19:13:44 10.77.50.10.5060 > 10.77.50.21.5060 SIP/2.0 401 Unauthorized
19:13:44 10.77.50.21.5060 > 10.77.50.10.5060 ACK sip:[email protected] SIP/2.0
19:13:44 10.77.50.21.5060 > 10.77.50.10.5060 INVITE sip:[email protected] SIP/2.0
19:13:44 10.77.50.10.5060 > 10.77.50.21.5060 SIP/2.0 100 Trying
19:13:44 10.77.50.10.5060 > 10.77.50.22.5060 INVITE sip:[email protected]:5060 SIP/2.0
19:13:44 10.77.50.22.5060 > 10.77.50.10.5060 SIP/2.0 180 Ringing
19:13:44 10.77.50.10.5060 > 10.77.50.21.5060 SIP/2.0 180 Ringing
19:13:44 10.77.50.22.5060 > 10.77.50.10.5060 SIP/2.0 200 Answering
19:13:44 10.77.50.10.5060 > 10.77.50.22.5060 ACK sip:[email protected]:5060 SIP/2.0
19:13:44 10.77.50.10.5060 > 10.77.50.21.5060 SIP/2.0 200 OK
19:13:44 10.77.50.21.5060 > 10.77.50.10.5060 ACK sip:10.77.50.10:5060 SIP/2.0
19:13:54 10.77.50.21.5060 > 10.77.50.10.5060 BYE sip:10.77.50.10:5060 SIP/2.0
19:13:54 10.77.50.10.5060 > 10.77.50.21.5060 SIP/2.0 200 OK
19:13:54 10.77.50.10.5060 > 10.77.50.22.5060 BYE sip:[email protected]:5060 SIP/2.0
19:13:54 10.77.50.22.5060 > 10.77.50.10.5060 SIP/2.0 200 OK
497 10.77.50.22.20008 > 10.77.50.10.10012
496 10.77.50.10.10068 > 10.77.50.21.20004
492 10.77.50.21.20004 > 10.77.50.10.10068
491 10.77.50.10.10012 > 10.77.50.22.20008
4 10.77.50.22.20009 > 10.77.50.10.10013
3 10.77.50.21.20005 > 10.77.50.10.10069
1 10.77.50.10.10069 > 10.77.50.21.20005
1 10.77.50.10.10013 > 10.77.50.22.20009
[0:00:10] audio=62657/63936 (bit/s)في المخرج أعلاه لاحظ التالي:
- الرسائل هي التسلسل نفسه الذي رسمناه في مقال المصطلحات: INVITE يحتاج المصادقة، ثم المقسم يبدأ INVITE جديداً إلى العنوان المحفوظ لبوب 10.77.50.22.
- هناك أربعة تدفقات RTP كبيرة، لأن المقسم في المنتصف: Bob إلى المقسم، والمقسم إلى Alice، وAlice إلى المقسم، والمقسم إلى Bob، وكل منها نحو 490 حزمة في عشر ثوان، أي 50 حزمة في الثانية.
- التدفقات الصغيرة على المنافذ الفردية 20009 و10013 هي تقارير RTCP.
- السطر الأخير من هاتف Alice يقول إنها ترسل وتستقبل نحو 64 kbit/s، وهو معدل G.711 بالضبط، أي أن الصوت وصل في الاتجاهين.
وإذا أردت أن ترى المكالمة كسلم رسائل تفاعلي بدل الأسطر، فشغل docker exec -it lab-voip-pbx sngrep -I /tmp/c1.pcap، واختر المكالمة بالسهم واضغط Enter، وهي الطريقة الأسرع لقراءة SIP على سيرفر حقيقي.
ننقل Bob خلف NAT
الآن نبني الراوتر: Container متصل بالشبكتين، يغير عنوان الحزم الخارجة من شبكة المكتب إلى عنوانه بقاعدة MASQUERADE، ويسمح من الخارج بالردود على اتصالات بدأت من الداخل فقط، كما يفعل أي راوتر مكتبي. ثم نجعل المقسم يرسل كل ما لا يعرفه عبر هذا الراوتر، حتى إذا حاول الوصول إلى عنوان خاص في شبكة المكتب يصل إلى الراوتر ويرفض هناك، تماماً كما يضيع العنوان الخاص في الإنترنت الحقيقي:
docker run -d --name lab-voip-nat --network lab-voip-wan --ip 10.77.50.2 \
--cap-add NET_ADMIN --sysctl net.ipv4.ip_forward=1 lab-voip-tools:1 sleep infinity
docker network connect --ip 192.168.77.254 lab-voip-lan lab-voip-nat
docker exec lab-voip-nat sh -c '
iptables -t nat -A POSTROUTING -s 192.168.77.0/24 -o eth0 -j MASQUERADE
iptables -A FORWARD -i eth0 -o eth1 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
iptables -A FORWARD -i eth0 -o eth1 -j DROP'
docker exec lab-voip-pbx ip route add default via 10.77.50.2
docker rm -f lab-voip-bob
docker run -d --name lab-voip-bob --network lab-voip-lan --ip 192.168.77.22 \
--cap-add NET_ADMIN --cap-add NET_RAW \
-v "$PWD/bob:/conf" -v "$PWD/sounds:/sounds:ro" -v "$PWD/rec-bob:/rec" \
lab-voip-tools:1 sh -c 'ip route replace default via 192.168.77.254 2>/dev/null; exec baresip -f /conf'
docker logs lab-voip-bob 2>&1 | tail -1العطل الأول: رفض التسجيل 403 Forbidden
قبل أن نصل إلى ال NAT نفسه نقابل أول عطل، والمخرج سوف يكون كما يلي:
reg: sip:[email protected]: 403 Forbidden (ArabRoot-Lab-PBX)وعند قراءة سجل المقسم ب docker logs lab-voip-pbx سوف تجد السبب:
[Oct 8 19:14:10] WARNING[36]: res_pjsip_registrar.c:769 register_aor_core: Registration attempt from endpoint '1002' (10.77.50.2:5060) to AOR '1002' will exceed max contacts of 1والسبب أن الهاتف القديم على العنوان 10.77.50.22 ما زال مسجلاً، ومدة تسجيله نحو ساعة، وقد حددنا max_contacts=1، فرفض المقسم التسجيل الجديد بدل أن يستبدل القديم، لأن القيمة الافتراضية للخيار remove_existing هي no. وهذا ما يحدث في الواقع عندما ينقل موظف حاسوبه من شبكة المكتب إلى شبكة البيت، أو عندما يتغير عنوان الراوتر العام. ولاحظ أيضاً أن المقسم يرى الطلب قادماً من 10.77.50.2، أي من الراوتر، وليس من عنوان Bob الحقيقي. والحل أن نضيف إلى جزء aor في التحويلتين:
remove_existing=yesثم نعيد تحميل الإعداد ونعيد تشغيل الهاتف:
docker exec lab-voip-pbx asterisk -rx 'pjsip reload'
docker restart lab-voip-bob
docker exec lab-voip-pbx asterisk -rx 'pjsip show contacts'والمخرج سوف يكون كما يلي:
Contact: 1002/sip:[email protected]:506 7f13451082 NonQual nan
Objects found: 1نجح التسجيل، ولكن انظر إلى العنوان الذي حفظه المقسم: 192.168.77.22، وهو العنوان الخاص لبوب داخل المكتب، والجدول يقطع آخر رقم من المنفذ 5060 لضيق العمود فقط. والمقسم حفظه لأن الهاتف كتبه بنفسه في الترويسة Contact، وال NAT غير عنوان الحزمة ولم يغير ما بداخلها.
العطل الثاني: الهاتف مسجل ولا يرن
نتصل الآن من Alice، وننتظر 25 ثانية:
./call.sh c2 25والمخرج سوف يكون كما يلي، بعد أن اختصرناه إلى الأسطر المهمة:
19:14:29 10.77.50.21.5060 > 10.77.50.10.5060 INVITE sip:[email protected] SIP/2.0
19:14:29 10.77.50.10.5060 > 10.77.50.21.5060 SIP/2.0 100 Trying
19:14:29 10.77.50.10.5060 > 192.168.77.22.5060 INVITE sip:[email protected]:5060 SIP/2.0
19:14:29 10.77.50.10.5060 > 192.168.77.22.5060 INVITE sip:[email protected]:5060 SIP/2.0
19:14:30 10.77.50.10.5060 > 192.168.77.22.5060 INVITE sip:[email protected]:5060 SIP/2.0
19:14:32 10.77.50.10.5060 > 192.168.77.22.5060 INVITE sip:[email protected]:5060 SIP/2.0
19:14:36 10.77.50.10.5060 > 192.168.77.22.5060 INVITE sip:[email protected]:5060 SIP/2.0
19:14:44 10.77.50.10.5060 > 192.168.77.22.5060 INVITE sip:[email protected]:5060 SIP/2.0
19:14:49 10.77.50.10.5060 > 10.77.50.21.5060 SIP/2.0 603 Declineالمقسم يرسل INVITE إلى العنوان الخاص 192.168.77.22، والراوتر يرفضه لأنه اتصال جديد من الخارج، فلا يأتي أي رد، فيعيد المقسم الإرسال بفواصل تتضاعف: نصف ثانية ثم ثانية ثم ثانيتان ثم أربع ثم ثمان، وهذه هي مؤقتات إعادة الإرسال عبر UDP في RFC 3261، ثم ينتهي وقت الانتظار الذي وضعناه في Dial بعد 20 ثانية فيرد المقسم على Alice ب 603 Decline. والمستخدم يرى في هذه الحالة أن «الطرف الآخر رفض المكالمة»، وهذا مضلل تماماً، فهاتف Bob لم يعلم بالمكالمة أصلاً.
rewrite_contact وforce_rport: الرد إلى مصدر الحزمة
الحل البسيط الذي يخطر على البال هو أن نكتب في إعداد الهاتف العنوان العام للراوتر بدل عنوانه الخاص، ولكن هذا الحل غير مناسب إطلاقاً، فالمنفذ الذي يختاره ال NAT يتغير، كما سنرى بعد قليل، وأغلب الهواتف تتنقل بين الشبكات. والطريقة الصحيحة أن يتجاهل المقسم ما يقوله الهاتف عن نفسه، ويعتمد على العنوان والمنفذ اللذين وصلت منهما الحزمة فعلاً، وهذا ما يفعله خياران في endpoint:
force_rport=yes: يرسل المقسم ردوده على أي طلب إلى العنوان والمنفذ اللذين جاء منهما الطلب، وليس إلى ما في ترويسةVia، وهو السلوك الذي يعرفه RFC 3581.rewrite_contact=yes: يستبدل المقسم العنوان في ترويسةContactعند التسجيل بعنوان مصدر الحزمة، فترسل المكالمات القادمة إلى الراوتر، ويمررها الراوتر إلى الهاتف لأنها تطابق الاتصال الذي فتحه الهاتف بالتسجيل.
نغير الخيارين في القالب، ثم نعيد التحميل ونلتقط تسجيل Bob لنرى ما يحدث:
sed -i 's/^force_rport=no/force_rport=yes/; s/^rewrite_contact=no/rewrite_contact=yes/' asterisk/pjsip.conf
docker exec lab-voip-pbx asterisk -rx 'pjsip reload'
docker exec -d lab-voip-pbx tcpdump -i eth0 -n -s0 -w /tmp/reg.pcap udp port 5060
docker restart lab-voip-bob; sleep 5; docker exec lab-voip-pbx pkill tcpdump
docker exec lab-voip-pbx sh -c "tcpdump -r /tmp/reg.pcap -n -A 2>/dev/null" | grep -E '^Via|^Contact'
docker exec lab-voip-pbx asterisk -rx 'pjsip show aor 1002' | grep -E '^ *contact 'والمخرج سوف يكون كما يلي، وقد أخذنا منه أسطر التسجيل الجديد:
Via: SIP/2.0/UDP 192.168.77.22:5060;branch=z9hG4bK63629205ffe0240b;rport
Contact: <sip:[email protected]:5060>;expires=3768
Via: SIP/2.0/UDP 192.168.77.22:5060;rport=5060;received=10.77.50.2;branch=z9hG4bK63629205ffe0240b
Contact: <sip:[email protected]:5060>;expires=3767
contact : sip:[email protected]:5060;x-ast-orig-host=192.168.77.22:5060في المخرج أعلاه لاحظ التالي:
- الهاتف ما زال يكتب عنوانه الخاص في
ViaوContact، فهو لا يعلم أنه خلف NAT. - المقسم يرد بإضافة
received=10.77.50.2وrport=5060، أي «رأيت طلبك قادماً من هذا العنوان وهذا المنفذ». - العنوان المحفوظ للتحويلة أصبح 10.77.50.2، وحفظ المقسم العنوان الأصلي في
x-ast-orig-hostللمعلومة فقط.
العطل الثالث: الصوت في اتجاه واحد One-way Audio
نتصل مرة أخرى، وأثناء المكالمة نسأل المقسم عن إحصاءات الصوت من نافذة أخرى بالأمر docker exec lab-voip-pbx asterisk -rx 'pjsip show channelstats':
./call.sh c3 10
docker exec lab-voip-bob ls -l /recوالمخرج سوف يكون كما يلي، بعد أن اختصرناه إلى الأسطر المهمة:
19:15:17 10.77.50.10.5060 > 10.77.50.2.5060 INVITE sip:[email protected]:5060 SIP/2.0
19:15:17 10.77.50.2.5060 > 10.77.50.10.5060 SIP/2.0 180 Ringing
19:15:17 10.77.50.10.5060 > 10.77.50.21.5060 SIP/2.0 180 Ringing
19:15:18 10.77.50.2.5060 > 10.77.50.10.5060 SIP/2.0 200 Answering
19:15:18 10.77.50.10.5060 > 10.77.50.2.5060 ACK sip:[email protected]:5060 SIP/2.0
19:15:18 10.77.50.10.5060 > 10.77.50.21.5060 SIP/2.0 200 OK
492 10.77.50.2.20012 > 10.77.50.10.10010
487 10.77.50.21.20018 > 10.77.50.10.10052
487 10.77.50.10.10010 > 192.168.77.22.20012
486 10.77.50.10.10052 > 10.77.50.21.20018
[0:00:09] audio=64426/64426 (bit/s)
-rw-r--r-- 1 root root 44 Oct 8 19:15 dump-2026-10-08-19-15-17-dec.wav
-rw-r--r-- 1 root root 157484 Oct 8 19:15 dump-2026-10-08-19-15-17-enc.wavالهاتف رن، وBob رد، والمكالمة قائمة، وAlice تستقبل الصوت بمعدل 64 kbit/s، أي أنها تسمع Bob. أما ملف dec.wav عند Bob، وهو ما سمعه، فحجمه 44 بايت فقط، وهو حجم ترويسة ملف WAV دون أي صوت، أي أن Bob لم يسمع شيئاً. والسبب في السطر الثالث من عداد الحزم: المقسم أرسل 487 حزمة صوت إلى 192.168.77.22، وهو العنوان الخاص الذي كتبه Bob في SDP داخل رده 200 OK، كما يظهر من الالتقاط نفسه:
Contact: <sip:[email protected]:5060>
o=- 3114984680 42264411 IN IP4 192.168.77.22
c=IN IP4 192.168.77.22
m=audio 20012 RTP/AVP 0 8 101والخياران السابقان يصلحان عنوان SIP فقط، أما عنوان الصوت في SDP فيبقى كما هو، والصورة التالية تبين ما يحدث للحزمة:

والأخطر من ذلك ما رأيناه في إحصاءات المقسم أثناء المكالمة:
BridgeId ChannelId ........ UpTime.. Codec. Count Lost Pct Jitter Count Lost Pct Jitter RTT....
fcb7d283 1001-00000004 00:00:09 ulaw 401 0 0 0.006 401 0 0 0.003 0.000
fcb7d283 1002-00000005 00:00:09 ulaw 401 0 0 0.000 401 0 0 0.003 0.000المقسم يقول إنه أرسل 401 حزمة إلى Bob دون أي فقد، وهذا صحيح من جهته، فالحزم خرجت منه فعلاً، ولكنه لا يعلم أنها ضاعت بعد ذلك. لذلك لا تقم بالحكم على مشكلة الصوت من إحصاءات المقسم وحدها، والسبب أن الذي يعرف هل وصل الصوت هو الطرف الذي يستقبله، فقارن دائماً ما خرج من المقسم بما وصل إلى الهاتف.
rtp_symmetric: الصوت في الاتجاهين
الحل هو الخيار rtp_symmetric=yes، وهو يجعل المقسم يرسل الصوت إلى العنوان والمنفذ اللذين يصل منهما صوت الهاتف فعلاً، بدل ما هو مكتوب في SDP، وهذا يعمل لأن أغلب الهواتف ترسل وتستقبل RTP على المنفذ نفسه، وهو ما يسمى RTP المتماثل Symmetric RTP:
sed -i 's/^rtp_symmetric=no/rtp_symmetric=yes/' asterisk/pjsip.conf
docker exec lab-voip-pbx asterisk -rx 'pjsip reload'
docker restart lab-voip-bob
./call.sh c4 10
docker exec lab-voip-bob ls -l /rec | tail -2والمخرج سوف يكون كما يلي، بعد أن اختصرناه إلى عداد الحزم والسطرين الأخيرين:
455 10.77.50.2.20016 > 10.77.50.10.10028
451 10.77.50.10.10046 > 10.77.50.21.20004
429 10.77.50.21.20004 > 10.77.50.10.10046
427 10.77.50.10.10028 > 10.77.50.2.20016
3 10.77.50.21.20005 > 10.77.50.10.10047
3 10.77.50.2.20017 > 10.77.50.10.10029
1 10.77.50.10.10047 > 10.77.50.21.20005
1 10.77.50.10.10029 > 10.77.50.2.20017
[0:00:09] audio=64000/64426 (bit/s)
-rw-r--r-- 1 root root 135084 Oct 8 19:16 dump-2026-10-08-19-15-55-dec.wav
-rw-r--r-- 1 root root 145644 Oct 8 19:16 dump-2026-10-08-19-15-55-enc.wavكل حزم المقسم ذهبت هذه المرة إلى 10.77.50.2:20016، وهو المصدر الذي جاء منه صوت Bob، ولم تذهب أي حزمة إلى العنوان الخاص، وملف ما سمعه Bob أصبح 135 KB، أي أن الصوت وصل في الاتجاهين. وقد رأينا في تشغيل سابق للتجربة أن المقسم أرسل ثلاث حزم إلى العنوان الخاص في أول المكالمة قبل أن يصله أول صوت من Bob ويتعلم العنوان الصحيح، وهذا طبيعي ولا يسمعه أحد.
العطل الرابع: جدار الحماية Firewall يغلق منافذ RTP
الآن نفترض أن من أعد السيرفر فتح منفذ SIP فقط في جدار الحماية، وهو خطأ شائع لأن الوثائق تذكر المنفذ 5060 في كل مكان، ونرى ماذا يحدث:
docker exec lab-voip-pbx sh -c 'iptables -A INPUT -p udp --dport 5060 -j ACCEPT; iptables -A INPUT -p udp -j DROP'
docker restart lab-voip-bob
./call.sh c5 10
docker exec lab-voip-pbx iptables -L INPUT -v -n
docker exec lab-voip-pbx iptables -F INPUTوالمخرج سوف يكون كما يلي:
[0:00:10] audio=63530/0 (bit/s)
Chain INPUT (policy ACCEPT 0 packets, 0 bytes)
pkts bytes target prot opt in out source destination
16 10456 ACCEPT 17 -- * * 0.0.0.0/0 0.0.0.0/0 udp dpt:5060
993 198K DROP 17 -- * * 0.0.0.0/0 0.0.0.0/0المكالمة اتصلت لأن SIP مسموح، ولكن Alice ترسل ولا تستقبل شيئاً، وملف ما سمعه Bob 44 بايت مرة أخرى، وعداد القاعدة الثانية يقول إن جدار الحماية أسقط 993 حزمة صوت من الطرفين. والحل أن تفتح نطاق المنافذ المكتوب في rtp.conf بالضبط، ولهذا اخترنا نطاقاً صغيراً من 10000 إلى 10099، فكل مكالمة تمر عبر المقسم تستخدم منفذين على الأقل في كل جهة، ونطاق أوسع يعني مكالمات أكثر في الوقت نفسه. وقد شرحنا ضبط جدار الحماية على السيرفر في تأمين خادم VPS من أول دخول.
الهاتف مسجل ولا يرن بعد ثوان من السكوت: مهلة NAT Timeout
بقي عطل أخير يظهر في الإنتاج بعد أيام وليس في أول تجربة: الهاتف يظهر مسجلاً في لوحة المقسم، ولكن المكالمات القادمة لا تصل إليه، ثم يعود فجأة إلى العمل عندما يجري الموظف مكالمة. والسبب أن الراوتر لا يحفظ ربط NAT Mapping إلى الأبد، وإنما يحذفه بعد مدة من السكوت، ومدة التسجيل في SIP ساعة كاملة، فبين تسجيل وآخر يحذف الراوتر الربط، وعندها يصبح INVITE القادم من المقسم اتصالاً جديداً من الخارج فيرفض. ولكي نرى هذا بسرعة نعيد تشغيل الراوتر بمهلة 30 ثانية لحزم UDP، ونترك Bob ساكناً 50 ثانية ثم نتصل به:
docker rm -f lab-voip-nat
docker run -d --name lab-voip-nat --network lab-voip-wan --ip 10.77.50.2 \
--cap-add NET_ADMIN --sysctl net.ipv4.ip_forward=1 \
--sysctl net.netfilter.nf_conntrack_udp_timeout=30 \
--sysctl net.netfilter.nf_conntrack_udp_timeout_stream=30 \
lab-voip-tools:1 sleep infinity
docker network connect --ip 192.168.77.254 lab-voip-lan lab-voip-nat
docker exec lab-voip-nat sh -c '
iptables -t nat -A POSTROUTING -s 192.168.77.0/24 -o eth0 -j MASQUERADE
iptables -A FORWARD -i eth0 -o eth1 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
iptables -A FORWARD -i eth0 -o eth1 -j DROP'
docker restart lab-voip-bob; sleep 50
./call.sh c6 8والمخرج سوف يكون كما يلي، بعد أن اختصرناه إلى الأسطر المهمة:
19:17:53 10.77.50.10.5060 > 10.77.50.2.5060 INVITE sip:[email protected]:5060 SIP/2.0
19:17:54 10.77.50.10.5060 > 10.77.50.2.5060 INVITE sip:[email protected]:5060 SIP/2.0
19:17:55 10.77.50.10.5060 > 10.77.50.2.5060 INVITE sip:[email protected]:5060 SIP/2.0
19:17:57 10.77.50.10.5060 > 10.77.50.2.5060 INVITE sip:[email protected]:5060 SIP/2.0
19:18:01 10.77.50.10.5060 > 10.77.50.2.5060 INVITE sip:[email protected]:5060 SIP/2.0
19:18:01 10.77.50.21.5060 > 10.77.50.10.5060 CANCEL sip:[email protected] SIP/2.0
19:18:01 10.77.50.10.5060 > 10.77.50.21.5060 SIP/2.0 487 Request Terminatedالعنوان صحيح هذه المرة، فهو عنوان الراوتر 10.77.50.2، ومع ذلك لا يرد أحد، لأن الراوتر نسي أن المنفذ 5060 عنده يخص Bob، وفي النهاية ألغت Alice المكالمة ب CANCEL. والحل أن يرسل المقسم إلى الهاتف رسالة صغيرة بشكل دوري، فيبقى الربط حياً في الراوتر، وهذا ما يفعله الخيار qualify_frequency في جزء aor، وقيمته الافتراضية 0 أي لا يوجد فحص، فنضيف إلى التحويلتين:
qualify_frequency=15ونعيد التحميل ونترك Bob ساكناً 50 ثانية مرة أخرى:
docker exec lab-voip-pbx asterisk -rx 'pjsip reload'
docker restart lab-voip-bob; sleep 50
docker exec lab-voip-pbx asterisk -rx 'pjsip show contacts'
./call.sh c7 8والمخرج سوف يكون كما يلي، بعد أن اختصرناه إلى الأسطر المهمة:
Contact: 1002/sip:[email protected]:14204; baf8c0212d Avail 0.793
Objects found: 1
19:19:15 10.77.50.10.5060 > 10.77.50.2.14204 INVITE sip:[email protected]:14204 SIP/2.0
19:19:15 10.77.50.2.14204 > 10.77.50.10.5060 SIP/2.0 180 Ringing
19:19:15 10.77.50.2.14204 > 10.77.50.10.5060 SIP/2.0 200 Answering
19:19:15 10.77.50.10.5060 > 10.77.50.2.14204 OPTIONS sip:[email protected]:14204 SIP/2.0
19:19:15 10.77.50.2.14204 > 10.77.50.10.5060 SIP/2.0 200 OK
[0:00:07] audio=64362/64788 (bit/s)في المخرج أعلاه لاحظ التالي:
- حالة الهاتف أصبحت
AvailبدلNonQual، ومعها زمن الرد RTT بالملي ثانية، فأصبح المقسم يعرف هل الهاتف حي فعلاً، وليس فقط هل سجل. - رسائل
OPTIONSوردودها تمر كل 15 ثانية، فلا يسكت الربط في الراوتر أكثر من مهلته، ووصل INVITE بعد 50 ثانية من السكوت. - الراوتر اختار هذه المرة المنفذ 14204 بدل 5060، وهذا دليل عملي على أن كتابة العنوان العام والمنفذ يدوياً في إعداد الهاتف لا تصلح، وأن
rewrite_contactهو الطريقة الصحيحة.
والصورة التالية تلخص الفرق:

والمهلة 30 ثانية في التجربة اخترناها لنرى الأثر بسرعة، أما في الراوترات الحقيقية فتختلف من جهاز لآخر، لذلك اجعل الفحص الدوري أقصر من أقصر مهلة تتوقعها، و15 إلى 30 ثانية قيمة شائعة.
الإعداد النهائي
وبهذا الشكل نكون قد وصلنا إلى قالب يعمل مع هواتف داخل الشبكة وخلف NAT، وهذا هو الجزء الذي تغير من pjsip.conf:
[phone](!)
type=endpoint
context=office
disallow=all
allow=ulaw,alaw
direct_media=no
rtp_symmetric=yes
force_rport=yes
rewrite_contact=yes
[1002]
type=aor
max_contacts=1
remove_existing=yes
qualify_frequency=15وهذه الخيارات هي نفسها التي تذكرها وثائق Asterisk للعمل عبر NAT في قسم الهواتف البعيدة خلف NAT، وقد رأينا الآن أثر كل خيار منها بنفسه.
قبل أن تنقل هذا إلى سيرفر حقيقي
التجربة تغطي حالة الهاتف خلف NAT والمقسم على عنوان عام، وفي الواقع هناك أمور إضافية:
- إذا كان المقسم نفسه خلف NAT، مثل سيرفر في السحابة له عنوان خاص وعنوان عام، فهو أيضاً يكتب عنوانه الخاص في SDP، والحل في جزء
transportبالخياراتexternal_media_addressوexternal_signaling_addressوlocal_netكما في الوثائق نفسها، وإلا تكرر عطل الصوت في اتجاه واحد من الجهة الأخرى. - افتح في جدار الحماية منفذ SIP ونطاق RTP كما في
rtp.confفقط، ولا تقم بفتح المنفذ 5060 لكل الإنترنت إذا كانت الهواتف كلها في مكاتبك، والسبب أن أي شخص على الإنترنت يستطيع عندها أن يجرب كلمات المرور على تحويلاتك، والأفضل أن تصل الفروع والموظفون عن بعد عبر شبكة خاصة كما في WireGuard، فيبقى SIP داخلها. - استخدم SIP عبر TLS على 5061 مع SRTP عندما يمر الصوت عبر الإنترنت، لأن RTP العادي يمكن تسجيله من أي نقطة في الطريق.
- استخدم إصداراً مدعوماً من Asterisk بحسب جدول الإصدارات، وليس حزمة قديمة من التوزيعة.
- إذا كانت مكالماتك من المتصفح عبر WebRTC، فطريق اكتشاف العناوين مختلف ويعتمد على ICE وSTUN وTURN، وقد شرحناه في مكالمات WebRTC خلف NAT.
وسوف نتناول في مقالات هذه المرحلة القادمة تأمين المقسم ضد الاحتيال على المكالمات بالتفصيل، وتثبيت Asterisk وFreePBX للإنتاج.
جدول التشخيص السريع
| العرض | السبب الأرجح | أين تنظر |
|---|---|---|
| التسجيل يرفض ب 403 بعد تغيير الشبكة | تسجيل قديم وmax_contacts ممتلئ | سجل المقسم will exceed max contacts، والحل remove_existing=yes |
| الهاتف مسجل ولا يرن أبداً، والمتصل يرى 603 أو 408 | المقسم يرسل INVITE إلى العنوان الخاص في Contact | pjsip show contacts، والحل rewrite_contact=yes |
| الهاتف يرن والطرف الذي خلف NAT لا يسمع | المقسم يرسل RTP إلى العنوان الخاص في SDP | عد حزم RTP في الالتقاط، والحل rtp_symmetric=yes |
| المكالمة تتصل ولا يسمع أحد الآخر | نطاق RTP مغلق في جدار الحماية | عدادات جدار الحماية، وrtp.conf |
| الهاتف يعمل ثم يتوقف عن الرنين بعد دقائق من السكوت | الراوتر حذف ربط NAT | NonQual في pjsip show contacts، والحل qualify_frequency |
| صوت في اتجاه واحد والمقسم نفسه خلف NAT | المقسم يكتب عنوانه الخاص في SDP | c= في SDP الخارج من المقسم، والحل external_media_address |
إزالة التجربة
نزيل ما أنشأناه بالاسم فقط، دون أوامر التنظيف العامة التي قد تحذف Containers أو Volumes تخص مشاريع أخرى على الجهاز نفسه:
docker rm -f lab-voip-alice lab-voip-bob lab-voip-nat lab-voip-pbx
docker network rm lab-voip-wan lab-voip-lan
docker rmi lab-voip-tools:1تدريب عملي
- افتح الالتقاط
/tmp/c3.pcapفي sngrep، وابحث عن السطرc=في رد200 OKالقادم من Bob، ثم قارنه بالعنوان الذي أرسل إليه المقسم الصوت في عداد الحزم. - أعد
rewrite_contactإلىnoمع بقاءqualify_frequency=15، وأعد تشغيل Bob، ثم اقرأ حالته فيpjsip show contacts، وفسر لماذا تغيرت.
الخلاصة
- الهاتف خلف NAT يكتب عنوانه الخاص في
Contactوفي SDP، وال NAT يغير عنوان الحزمة فقط، فيبقى العنوان الخاص داخل الرسالة. rewrite_contactوforce_rportيجعلان المقسم يرسل SIP إلى مصدر الحزمة، فيرن الهاتف، وrtp_symmetricيفعل الشيء نفسه للصوت، فيسمع الطرفان بعضهما.- الصوت في اتجاه واحد لا يظهر في إحصاءات المقسم، فقارن ما خرج منه بما وصل إلى الهاتف، والتقاط tcpdump مع عداد بسيط للحزم يكفي لمعرفة السبب.
- افتح نطاق RTP المحدد في
rtp.confمع منفذ SIP، وإلا اتصلت المكالمة دون صوت. - الفحص الدوري
qualify_frequencyيبقي ربط NAT حياً ويكشف الهاتف الذي اختفى، وremove_existingيمنع رفض الهاتف الذي غير شبكته. - إذا كان المقسم نفسه خلف NAT فأضف
external_media_addressوexternal_signaling_address، واستخدم إصداراً مدعوماً من Asterisk وTLS وSRTP في الإنتاج.
دليلمصطلحات الاتصالات الهاتفية VoIP للمبتدئينشرح مبسط لمصطلحات الهاتف عبر الإنترنت، وما يمر في الشبكة بين ضغط زر الاتصال ووصول الصوت إلى الطرف الآخر.
دليلما هو NAT؟ شرح ترجمة العناوين وتوجيه المنافذلماذا تخرج كل أجهزة بيتك بعنوان عام واحد، وكيف يترجم ال Router العناوين، ولماذا يفشل توجيه المنافذ خلف CGNAT.
دليلمكالمات WebRTC خلف NAT: شرح STUN وTURN وICEلماذا تتصل المكالمة ولا يصل الصوت، وكيف تحل STUN وTURN المشكلة دون أن تفتح باباً إلى شبكتك الداخلية.سجل التحديثات
- أكتوبر 2026: كتابة المقال، وبناء التجربة على Asterisk 20.6.0 وbaresip 1.0.0 وtcpdump 4.99.4 من حزم Ubuntu 24.04 في Docker، مع راوتر NAT بقواعد iptables.