diff --git a/config.yml b/config.yml index 966cce7..2f8f334 100644 --- a/config.yml +++ b/config.yml @@ -1,9 +1,14 @@ --- bridge_networks: - name: osbr0 + # pin the host-side bridge device name so a host-networked capture + # sidecar (rtp_relay set) can tcpdump -i osbr0 and see inter-container + # traffic; harmless for every other set + device: osbr0 subnet: {{ network_range }} gateway: {{ network_gateway }} - name: osbr1 + device: osbr1 subnet: {{ network_range_osbr1 }} gateway: {{ network_gateway_osbr1 }} diff --git a/defines.yml b/defines.yml index 8127eeb..50525ba 100644 --- a/defines.yml +++ b/defines.yml @@ -16,3 +16,14 @@ mysql_root_host: 192.168.52.% username: testing password: T3st|n6 nonce: 035cf70b1c4fcf4731150330410151743443 + +# --- rtp_relay / rtpengine (used only by the rtp_relay test set) --- +rtpengine_ip: 192.168.52.4 +rtpengine_ng_port: 22222 +# Stock Debian base; the rtp_relay rtpengine task apt-installs the +# rtpengine-daemon package (sipwise upstream, userspace-only) at start. +rtpengine_image: debian:trixie-slim +# packet-capture sidecar (host networking, sniffs the osbr0 bridge device) +capture_image: nicolaka/netshoot +# pure-python ng checker; python-opensips already ships python3 +checker_image: ghcr.io/opensips/python-opensips diff --git a/rtp_relay/01.initial-offer-savpf/opensips.cfg b/rtp_relay/01.initial-offer-savpf/opensips.cfg new file mode 100644 index 0000000..ca81773 --- /dev/null +++ b/rtp_relay/01.initial-offer-savpf/opensips.cfg @@ -0,0 +1,79 @@ +# +# GH-3902 rtp_relay -- SIP -> WebRTC transcoding proxy. +# Caller leg is plain RTP/AVP (request route); the callee WebRTC profile +# (UDP/TLS/RTP/SAVPF + ICE=force) is set in branch_route[webrtc]. Affected +# OpenSIPS never links that branch leg into sess->legs[CALLEE] before the offer, +# so rtp_relay asks rtpengine for plain RTP/AVP -- the #3902 bug. The checker +# inspects the ng OFFER/ANSWER OpenSIPS sends on the rtpengine ng port. +# +log_level=4 # DBG: rtp_relay_offer prints type=[...] peer-flags=[...] +stderror_enabled=yes + +udp_workers=4 + +socket=udp:192.168.52.1:5060 + +####### Modules ####### +mpath="/usr/lib/x86_64-linux-gnu/opensips/modules/" +loadmodule "proto_udp.so" +loadmodule "tm.so" +loadmodule "sl.so" +loadmodule "rr.so" +loadmodule "maxfwd.so" +loadmodule "sipmsgops.so" +loadmodule "dialog.so" +loadmodule "rtpengine.so" +loadmodule "rtp_relay.so" + +modparam("rtpengine", "rtpengine_sock", "udp:192.168.52.4:22222") + +####### Routing ####### +route{ + if (!mf_process_maxfwd_header(10)) { + sl_send_reply(483,"Too Many Hops"); + exit; + } + + if (has_totag()) { + if (loose_route()) { + xlog("L_NOTICE","SEQ $rm cseq=$cs\n"); + t_relay(); + exit; + } + sl_send_reply(404,"Not here"); + exit; + } + + if (is_method("CANCEL")) { + t_relay(); + exit; + } + + if (is_method("INVITE")) { + xlog("L_NOTICE","INITIAL INVITE -- caller(RTP/AVP) leg + engage\n"); + create_dialog(); + + # ---- caller (inbound) leg: plain SIP, RTP/AVP ---- + $rtp_relay(flags) = "replace-origin replace-session-connection"; + $rtp_relay(type) = "RTP/AVP"; + $rtp_relay(peer) = "ICE=remove"; + + rtp_relay_engage("rtpengine"); + record_route(); + $du = "sip:192.168.52.3:5080"; + t_on_branch("webrtc"); + t_relay(); + exit; + } + + sl_send_reply(405,"Method Not Allowed"); +} + +# ---- callee (outbound) leg: WebRTC, provisioned at the branch index ---- +# This is the leg that affected OpenSIPS fails to link into sess->legs[CALLEE]. +branch_route[webrtc] { + xlog("L_NOTICE","BRANCH webrtc -- provisioning callee WebRTC profile\n"); + $rtp_relay(flags) = "replace-origin replace-session-connection"; + $rtp_relay(type) = "UDP/TLS/RTP/SAVPF"; + $rtp_relay(peer) = "ICE=force rtcp-mux-require SDES-off"; +} diff --git a/rtp_relay/01.initial-offer-savpf/scenario.yml b/rtp_relay/01.initial-offer-savpf/scenario.yml new file mode 100644 index 0000000..24d59df --- /dev/null +++ b/rtp_relay/01.initial-offer-savpf/scenario.yml @@ -0,0 +1,100 @@ +--- +# GH-3902 rtp_relay -- initial SDP-in-INVITE offer. +# +# A plain SIP caller (RTP/AVP) calls a callee whose WebRTC profile +# (UDP/TLS/RTP/SAVPF + ICE=force) is provisioned in branch_route[webrtc]. The +# checker asserts the ng OFFER OpenSIPS sends to rtpengine carries that profile. +# On affected OpenSIPS the branch-provisioned callee leg is not linked before +# the offer, so the profile is dropped (RED). With the fix it survives (GREEN). +# +# The checker inspects what OpenSIPS ASKS rtpengine (transport-protocol / ICE), +# independent of any real WebRTC endpoint -- so a trivial SIP UAS suffices. + +timeout: 300 + +tasks: + # rtpengine media proxy (userspace only, no kernel module / privileges). + - name: rtpengine + type: generic + image: {{ rtpengine_image }} + ip: {{ rtpengine_ip }} + daemon: true + stop_timeout: 3 + entrypoint: sh + # debian:slim base -> install the official Debian rtpengine-daemon package + # (sipwise upstream; userspace-only, no kernel dkms). policy-rc.d keeps the + # package postinst from starting a service; iproute2 provides `ss` for the + # readiness healthcheck below. Then run rtpengine in the foreground. + args: ["-c", "echo 'exit 101' > /usr/sbin/policy-rc.d && chmod +x /usr/sbin/policy-rc.d && apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends rtpengine-daemon iproute2 && exec rtpengine --table=-1 --interface={{ rtpengine_ip }} --listen-ng={{ rtpengine_ip }}:{{ rtpengine_ng_port }} --foreground --log-stderr"] + # healthy once the ng socket is bound, so dependents gate on readiness (not a + # fixed sleep) and the apt-install delay cannot race the proxy startup ping. + healthcheck: + test: "ss -lnu | grep -q :{{ rtpengine_ng_port }}" + interval: 2000000000 + timeout: 2000000000 + retries: 120 + + # Sidecar that captures the rtpengine ng UDP stream into a shared rw volume. + # It runs with HOST networking and sniffs the osbr0 bridge DEVICE, so it sees + # the OpenSIPS<->rtpengine traffic between two other containers (a normal + # bridge-attached sidecar would only see its own traffic). The built-in + # sipssert tracer also captures capture.pcap on osbr0, but only finalizes it + # after the scenario ends, so a mid-run checker task cannot read it -- hence + # this dedicated capture into a volume the checker then mounts. + - name: ng capture + type: generic + image: {{ capture_image }} + network: host + daemon: true + stop_timeout: 2 + volumes: + - caps + entrypoint: sh + args: ["-c", "tcpdump -i osbr0 -n -s0 -U -w /caps/ng.pcap udp port {{ rtpengine_ng_port }}"] + require: + healthy: rtpengine + + - name: OpenSIPS + type: opensips + require: + - healthy: rtpengine + - started: + task: ng capture + wait: 0.5 + + - name: SIPP UAS + type: uas-sipp + config_file: scripts/uas.xml + require: OpenSIPS + + - name: SIPP UAC + type: uac-sipp + config_file: scripts/uac.xml + remote: {{ uas_ip }}:{{ uas_port }} + caller: caller + calls: 1 + require: + - started: + task: SIPP UAS + wait: 0.5 + - after: + task: OpenSIPS + wait: 0.5 + + # ng checker: PASS iff the OFFER carries UDP/TLS/RTP/SAVPF + ICE=force. + - name: ng checker + type: generic + image: {{ checker_image }} + volumes: + - caps + entrypoint: python3 + args: ["/home/scripts/ng_checker.py", "/caps/ng.pcap", "savpf", "--ng-port", "{{ rtpengine_ng_port }}"] + require: + after: + task: SIPP UAC + wait: 2 + +volumes: + caps: + bind: /caps + mode: rw diff --git a/rtp_relay/01.initial-offer-savpf/scripts/ng_checker.py b/rtp_relay/01.initial-offer-savpf/scripts/ng_checker.py new file mode 100644 index 0000000..13043f5 --- /dev/null +++ b/rtp_relay/01.initial-offer-savpf/scripts/ng_checker.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +"""GH-3902 rtp_relay checker -- assert what OpenSIPS ASKS rtpengine. + +Dependency-free (stdlib only, no tshark/scapy): parses a tcpdump pcap and decodes +the rtpengine "ng" bencode control messages. The bug (#3902): the callee/WebRTC +profile provisioned in the branch_route is not linked into sess->legs[CALLEE] +before the offer, so rtp_relay asks rtpengine for plain RTP/AVP -- losing +UDP/TLS/RTP/SAVPF + ICE=force. We read the ng OFFER/ANSWER on udp: and +assert the profile is present. + + ng_checker.py savpf [--ng-port N] + PASS iff some OFFER carries UDP/TLS/RTP/SAVPF + ICE=force. + ng_checker.py count [offer|answer] [--ng-port N] + PASS iff >= min_offers OFFERs AND every msg of that axis keeps SAVPF. + ng_checker.py failover --mi-port M [--ng-port N] + PASS iff a re-anchor OFFER/ANSWER after the rtp_relay_update MI keeps SAVPF. + +Exit 0 = PASS, non-zero = FAIL. +""" +import struct +import sys + +WANT_PROTO = b"UDP/TLS/RTP/SAVPF" +WANT_ICE = b"force" +DEFAULT_NG_PORT = 22222 + + +def read_pcap(path): + """Yield (ts_relative, linktype, frame) per record of a classic pcap.""" + with open(path, "rb") as f: + gh = f.read(24) + if len(gh) < 24: + return + magic = gh[:4] + if magic in (b"\xd4\xc3\xb2\xa1", b"\x4d\x3c\xb2\xa1"): + endian = "<" + elif magic in (b"\xa1\xb2\xc3\xd4", b"\xa1\xb2\x3c\x4d"): + endian = ">" + else: + raise SystemExit("not a classic pcap (magic=%r); use tcpdump default " + "output, not pcapng" % magic) + nanos = magic in (b"\x4d\x3c\xb2\xa1", b"\xa1\xb2\x3c\x4d") + linktype = struct.unpack(endian + "I", gh[20:24])[0] + t0 = None + while True: + rh = f.read(16) + if len(rh) < 16: + return + ts_sec, ts_frac, caplen, _orig = struct.unpack(endian + "IIII", rh) + data = f.read(caplen) + if len(data) < caplen: + return + ts = ts_sec + (ts_frac / 1e9 if nanos else ts_frac / 1e6) + if t0 is None: + t0 = ts + yield (ts - t0, linktype, data) + + +def udp_payloads(path): + """Yield (ts, dst_port, payload) for each IPv4/UDP packet.""" + for ts, linktype, data in read_pcap(path): + if linktype == 1: # Ethernet (tcpdump -i osbr0) + if len(data) < 14 or struct.unpack("!H", data[12:14])[0] != 0x0800: + continue + off = 14 + elif linktype == 113: # Linux cooked + if len(data) < 16 or struct.unpack("!H", data[14:16])[0] != 0x0800: + continue + off = 16 + else: + continue + ip = data[off:] + if len(ip) < 20 or (ip[0] >> 4) != 4 or ip[9] != 17: # IPv4 + UDP + continue + udp = ip[(ip[0] & 0x0F) * 4:] + if len(udp) < 8: + continue + yield (ts, struct.unpack("!H", udp[2:4])[0], udp[8:]) + + +def command_of(msg): + """Extract the bencode 7:command value (e.g. 'offer'/'answer') or None.""" + i = msg.find(b"7:command") + if i < 0: + return None + colon = msg.find(b":", i + 9) + if colon < 0: + return None + try: + n = int(msg[i + 9:colon]) + except ValueError: + return None + return msg[colon + 1:colon + 1 + n].decode("latin1") + + +def ng_requests(path, ng_port): + """[(ts, payload)] for ng requests sent TO rtpengine.""" + return [(ts, p) for ts, dport, p in udp_payloads(path) + if dport == ng_port and b"7:command" in p] + + +def mi_time(path, mi_port): + """Relative time of the rtp_relay_update MI request, or None.""" + for ts, dport, p in udp_payloads(path): + if dport == mi_port and b"rtp_relay_update" in p: + return ts + return None + + +def fail(msg): + print("FAIL: " + msg) + return 1 + + +def check_savpf(reqs): + offers = [m for _t, m in reqs if command_of(m) == "offer"] + print("ng requests: %d, OFFERs: %d" % (len(reqs), len(offers))) + if not offers: + return fail("no OFFER ng message captured -- harness/capture problem") + if any(WANT_PROTO in m for m in offers) and any(WANT_ICE in m for m in offers): + print("PASS: OFFER carries UDP/TLS/RTP/SAVPF + ICE=force") + return 0 + return fail("OFFER missing the callee transcode-to profile -> proxy asked " + "rtpengine for plain RTP/AVP -> WebRTC UA would 488 (#3902)") + + +def check_count(reqs, want, where): + cmds = [(m, command_of(m)) for _t, m in reqs] + offers = [m for m, c in cmds if c == "offer"] + target = [m for m, c in cmds if c == ("answer" if where == "answer" else "offer")] + savpf = [m for m in target if WANT_PROTO in m] + print("OFFERs: %d (want >= %d), %s carrying SAVPF: %d/%d" + % (len(offers), want, where, len(savpf), len(target))) + if len(offers) < want: + return fail("only %d/%d offers -> a re-INVITE did not re-anchor" + % (len(offers), want)) + if not target or len(savpf) != len(target): + return fail("%d/%d %s msg(s) lost the WebRTC profile -> #3902 present" + % (len(target) - len(savpf), len(target), where)) + print("PASS: re-anchored AND all %d %s msg(s) kept UDP/TLS/RTP/SAVPF" + % (len(target), where)) + return 0 + + +def check_failover(reqs, t0): + if t0 is None: + return fail("no rtp_relay_update MI request seen on the MI port") + msgs = [(command_of(m), WANT_PROTO in m) for t, m in reqs + if t >= t0 and command_of(m) in ("offer", "answer")] + print("MI fired at t=%.2fs, re-anchor offer/answer: %d" % (t0, len(msgs))) + if not msgs: + return fail("rtp_relay_update generated no re-anchor offer/answer") + if not any(s for _c, s in msgs): + return fail("failover re-anchor lost the WebRTC profile (all RTP/AVP) " + "-> WebRTC node would 488 the re-INVITE -> #3902 present") + print("PASS: failover re-anchor preserved UDP/TLS/RTP/SAVPF") + return 0 + + +def main(argv): + if len(argv) < 3: + print(__doc__) + return 2 + pcap, mode, rest = argv[1], argv[2], argv[3:] + ng_port, mi_port, pos, i = DEFAULT_NG_PORT, None, [], 0 + while i < len(rest): + if rest[i] == "--ng-port": + ng_port = int(rest[i + 1]); i += 2 + elif rest[i] == "--mi-port": + mi_port = int(rest[i + 1]); i += 2 + else: + pos.append(rest[i]); i += 1 + + print("GH-3902 rtp_relay ng checker mode=%s ng-port=%d" % (mode, ng_port)) + reqs = ng_requests(pcap, ng_port) + if mode == "savpf": + return check_savpf(reqs) + if mode == "count": + if not pos: + return fail("count mode needs ") + return check_count(reqs, int(pos[0]), pos[1] if len(pos) > 1 else "offer") + if mode == "failover": + if mi_port is None: + return fail("failover mode needs --mi-port N") + return check_failover(reqs, mi_time(pcap, mi_port)) + return fail("unknown mode %r" % mode) + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/rtp_relay/01.initial-offer-savpf/scripts/uac.xml b/rtp_relay/01.initial-offer-savpf/scripts/uac.xml new file mode 100644 index 0000000..97aa4f4 --- /dev/null +++ b/rtp_relay/01.initial-offer-savpf/scripts/uac.xml @@ -0,0 +1,73 @@ + + + + + + + ;tag=[pid]SIPpTag00[call_number] + To: callee + Call-ID: [call_id] + CSeq: 1 INVITE + Contact: sip:caller@[local_ip]:[local_port] + Max-Forwards: 70 + Content-Type: application/sdp + Content-Length: [len] + + v=0 + o=caller 53655765 2353687637 IN IP[local_ip_type] [local_ip] + s=- + c=IN IP[media_ip_type] [media_ip] + t=0 0 + m=audio [media_port] RTP/AVP 8 0 101 + a=rtpmap:8 PCMA/8000 + a=rtpmap:0 PCMU/8000 + a=rtpmap:101 telephone-event/8000 + a=sendrecv + ]]> + + + + + + + + ;tag=[pid]SIPpTag00[call_number] + To: callee [peer_tag_param] + Call-ID: [call_id] + CSeq: 1 ACK + Contact: sip:caller@[local_ip]:[local_port] + Max-Forwards: 70 + [routes] + Content-Length: 0 + + ]]> + + + + + + ;tag=[pid]SIPpTag00[call_number] + To: callee [peer_tag_param] + Call-ID: [call_id] + CSeq: 2 BYE + Contact: sip:caller@[local_ip]:[local_port] + Max-Forwards: 70 + [routes] + Content-Length: 0 + + ]]> + + + + diff --git a/rtp_relay/01.initial-offer-savpf/scripts/uas.xml b/rtp_relay/01.initial-offer-savpf/scripts/uas.xml new file mode 100644 index 0000000..83a3e06 --- /dev/null +++ b/rtp_relay/01.initial-offer-savpf/scripts/uas.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + Content-Type: application/sdp + Content-Length: [len] + + v=0 + o=callee 7766554 7766554 IN IP[local_ip_type] [local_ip] + s=- + c=IN IP[media_ip_type] [media_ip] + t=0 0 + m=audio [media_port] RTP/AVP 8 + a=rtpmap:8 PCMA/8000 + a=sendrecv + ]]> + + + + + + + + + + + diff --git a/rtp_relay/02.indialog-reinvite-hold-unhold/opensips.cfg b/rtp_relay/02.indialog-reinvite-hold-unhold/opensips.cfg new file mode 100644 index 0000000..ca81773 --- /dev/null +++ b/rtp_relay/02.indialog-reinvite-hold-unhold/opensips.cfg @@ -0,0 +1,79 @@ +# +# GH-3902 rtp_relay -- SIP -> WebRTC transcoding proxy. +# Caller leg is plain RTP/AVP (request route); the callee WebRTC profile +# (UDP/TLS/RTP/SAVPF + ICE=force) is set in branch_route[webrtc]. Affected +# OpenSIPS never links that branch leg into sess->legs[CALLEE] before the offer, +# so rtp_relay asks rtpengine for plain RTP/AVP -- the #3902 bug. The checker +# inspects the ng OFFER/ANSWER OpenSIPS sends on the rtpengine ng port. +# +log_level=4 # DBG: rtp_relay_offer prints type=[...] peer-flags=[...] +stderror_enabled=yes + +udp_workers=4 + +socket=udp:192.168.52.1:5060 + +####### Modules ####### +mpath="/usr/lib/x86_64-linux-gnu/opensips/modules/" +loadmodule "proto_udp.so" +loadmodule "tm.so" +loadmodule "sl.so" +loadmodule "rr.so" +loadmodule "maxfwd.so" +loadmodule "sipmsgops.so" +loadmodule "dialog.so" +loadmodule "rtpengine.so" +loadmodule "rtp_relay.so" + +modparam("rtpengine", "rtpengine_sock", "udp:192.168.52.4:22222") + +####### Routing ####### +route{ + if (!mf_process_maxfwd_header(10)) { + sl_send_reply(483,"Too Many Hops"); + exit; + } + + if (has_totag()) { + if (loose_route()) { + xlog("L_NOTICE","SEQ $rm cseq=$cs\n"); + t_relay(); + exit; + } + sl_send_reply(404,"Not here"); + exit; + } + + if (is_method("CANCEL")) { + t_relay(); + exit; + } + + if (is_method("INVITE")) { + xlog("L_NOTICE","INITIAL INVITE -- caller(RTP/AVP) leg + engage\n"); + create_dialog(); + + # ---- caller (inbound) leg: plain SIP, RTP/AVP ---- + $rtp_relay(flags) = "replace-origin replace-session-connection"; + $rtp_relay(type) = "RTP/AVP"; + $rtp_relay(peer) = "ICE=remove"; + + rtp_relay_engage("rtpengine"); + record_route(); + $du = "sip:192.168.52.3:5080"; + t_on_branch("webrtc"); + t_relay(); + exit; + } + + sl_send_reply(405,"Method Not Allowed"); +} + +# ---- callee (outbound) leg: WebRTC, provisioned at the branch index ---- +# This is the leg that affected OpenSIPS fails to link into sess->legs[CALLEE]. +branch_route[webrtc] { + xlog("L_NOTICE","BRANCH webrtc -- provisioning callee WebRTC profile\n"); + $rtp_relay(flags) = "replace-origin replace-session-connection"; + $rtp_relay(type) = "UDP/TLS/RTP/SAVPF"; + $rtp_relay(peer) = "ICE=force rtcp-mux-require SDES-off"; +} diff --git a/rtp_relay/02.indialog-reinvite-hold-unhold/scenario.yml b/rtp_relay/02.indialog-reinvite-hold-unhold/scenario.yml new file mode 100644 index 0000000..eaa5ad4 --- /dev/null +++ b/rtp_relay/02.indialog-reinvite-hold-unhold/scenario.yml @@ -0,0 +1,93 @@ +--- +# GH-3902 rtp_relay -- in-dialog re-INVITE (hold / unhold). This is the issue +# title: "in-dialog re-INVITE (hold/unhold) not re-anchored for proxied dialog". +# +# The caller establishes the transcoding call, then sends a hold re-INVITE and +# an unhold re-INVITE. The checker counts the rtpengine ng OFFER commands (one +# per INVITE -> 3 re-anchors) AND checks every OFFER still carries the callee +# WebRTC profile (UDP/TLS/RTP/SAVPF). On affected OpenSIPS the re-INVITEs +# re-anchor but each offer drops the profile to plain RTP/AVP (RED); with the +# fix all three offers keep UDP/TLS/RTP/SAVPF (GREEN). + +timeout: 300 + +tasks: + - name: rtpengine + type: generic + image: {{ rtpengine_image }} + ip: {{ rtpengine_ip }} + daemon: true + stop_timeout: 3 + entrypoint: sh + # debian:slim base -> install the official Debian rtpengine-daemon package + # (sipwise upstream; userspace-only, no kernel dkms). policy-rc.d keeps the + # package postinst from starting a service; iproute2 provides `ss` for the + # readiness healthcheck below. Then run rtpengine in the foreground. + args: ["-c", "echo 'exit 101' > /usr/sbin/policy-rc.d && chmod +x /usr/sbin/policy-rc.d && apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends rtpengine-daemon iproute2 && exec rtpengine --table=-1 --interface={{ rtpengine_ip }} --listen-ng={{ rtpengine_ip }}:{{ rtpengine_ng_port }} --foreground --log-stderr"] + # healthy once the ng socket is bound, so dependents gate on readiness (not a + # fixed sleep) and the apt-install delay cannot race the proxy startup ping. + healthcheck: + test: "ss -lnu | grep -q :{{ rtpengine_ng_port }}" + interval: 2000000000 + timeout: 2000000000 + retries: 120 + + # host-networked sniffer on the osbr0 bridge device (see 01.initial-offer-savpf + # for why a bridge-attached -i any sidecar would not see inter-container ng). + - name: ng capture + type: generic + image: {{ capture_image }} + network: host + daemon: true + stop_timeout: 2 + volumes: + - caps + entrypoint: sh + args: ["-c", "tcpdump -i osbr0 -n -s0 -U -w /caps/ng.pcap udp port {{ rtpengine_ng_port }}"] + require: + healthy: rtpengine + + - name: OpenSIPS + type: opensips + require: + - healthy: rtpengine + - started: + task: ng capture + wait: 0.5 + + - name: SIPP UAS + type: uas-sipp + config_file: scripts/uas.xml + require: OpenSIPS + + - name: SIPP UAC + type: uac-sipp + config_file: scripts/uac.xml + remote: {{ uas_ip }}:{{ uas_port }} + caller: caller + calls: 1 + require: + - started: + task: SIPP UAS + wait: 0.5 + - after: + task: OpenSIPS + wait: 0.5 + + # ng checker: >= 3 OFFERs (INVITE + hold + unhold), all keeping the profile. + - name: ng checker + type: generic + image: {{ checker_image }} + volumes: + - caps + entrypoint: python3 + args: ["/home/scripts/ng_checker.py", "/caps/ng.pcap", "count", "3", "offer", "--ng-port", "{{ rtpengine_ng_port }}"] + require: + after: + task: SIPP UAC + wait: 2 + +volumes: + caps: + bind: /caps + mode: rw diff --git a/rtp_relay/02.indialog-reinvite-hold-unhold/scripts/ng_checker.py b/rtp_relay/02.indialog-reinvite-hold-unhold/scripts/ng_checker.py new file mode 100644 index 0000000..13043f5 --- /dev/null +++ b/rtp_relay/02.indialog-reinvite-hold-unhold/scripts/ng_checker.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +"""GH-3902 rtp_relay checker -- assert what OpenSIPS ASKS rtpengine. + +Dependency-free (stdlib only, no tshark/scapy): parses a tcpdump pcap and decodes +the rtpengine "ng" bencode control messages. The bug (#3902): the callee/WebRTC +profile provisioned in the branch_route is not linked into sess->legs[CALLEE] +before the offer, so rtp_relay asks rtpengine for plain RTP/AVP -- losing +UDP/TLS/RTP/SAVPF + ICE=force. We read the ng OFFER/ANSWER on udp: and +assert the profile is present. + + ng_checker.py savpf [--ng-port N] + PASS iff some OFFER carries UDP/TLS/RTP/SAVPF + ICE=force. + ng_checker.py count [offer|answer] [--ng-port N] + PASS iff >= min_offers OFFERs AND every msg of that axis keeps SAVPF. + ng_checker.py failover --mi-port M [--ng-port N] + PASS iff a re-anchor OFFER/ANSWER after the rtp_relay_update MI keeps SAVPF. + +Exit 0 = PASS, non-zero = FAIL. +""" +import struct +import sys + +WANT_PROTO = b"UDP/TLS/RTP/SAVPF" +WANT_ICE = b"force" +DEFAULT_NG_PORT = 22222 + + +def read_pcap(path): + """Yield (ts_relative, linktype, frame) per record of a classic pcap.""" + with open(path, "rb") as f: + gh = f.read(24) + if len(gh) < 24: + return + magic = gh[:4] + if magic in (b"\xd4\xc3\xb2\xa1", b"\x4d\x3c\xb2\xa1"): + endian = "<" + elif magic in (b"\xa1\xb2\xc3\xd4", b"\xa1\xb2\x3c\x4d"): + endian = ">" + else: + raise SystemExit("not a classic pcap (magic=%r); use tcpdump default " + "output, not pcapng" % magic) + nanos = magic in (b"\x4d\x3c\xb2\xa1", b"\xa1\xb2\x3c\x4d") + linktype = struct.unpack(endian + "I", gh[20:24])[0] + t0 = None + while True: + rh = f.read(16) + if len(rh) < 16: + return + ts_sec, ts_frac, caplen, _orig = struct.unpack(endian + "IIII", rh) + data = f.read(caplen) + if len(data) < caplen: + return + ts = ts_sec + (ts_frac / 1e9 if nanos else ts_frac / 1e6) + if t0 is None: + t0 = ts + yield (ts - t0, linktype, data) + + +def udp_payloads(path): + """Yield (ts, dst_port, payload) for each IPv4/UDP packet.""" + for ts, linktype, data in read_pcap(path): + if linktype == 1: # Ethernet (tcpdump -i osbr0) + if len(data) < 14 or struct.unpack("!H", data[12:14])[0] != 0x0800: + continue + off = 14 + elif linktype == 113: # Linux cooked + if len(data) < 16 or struct.unpack("!H", data[14:16])[0] != 0x0800: + continue + off = 16 + else: + continue + ip = data[off:] + if len(ip) < 20 or (ip[0] >> 4) != 4 or ip[9] != 17: # IPv4 + UDP + continue + udp = ip[(ip[0] & 0x0F) * 4:] + if len(udp) < 8: + continue + yield (ts, struct.unpack("!H", udp[2:4])[0], udp[8:]) + + +def command_of(msg): + """Extract the bencode 7:command value (e.g. 'offer'/'answer') or None.""" + i = msg.find(b"7:command") + if i < 0: + return None + colon = msg.find(b":", i + 9) + if colon < 0: + return None + try: + n = int(msg[i + 9:colon]) + except ValueError: + return None + return msg[colon + 1:colon + 1 + n].decode("latin1") + + +def ng_requests(path, ng_port): + """[(ts, payload)] for ng requests sent TO rtpengine.""" + return [(ts, p) for ts, dport, p in udp_payloads(path) + if dport == ng_port and b"7:command" in p] + + +def mi_time(path, mi_port): + """Relative time of the rtp_relay_update MI request, or None.""" + for ts, dport, p in udp_payloads(path): + if dport == mi_port and b"rtp_relay_update" in p: + return ts + return None + + +def fail(msg): + print("FAIL: " + msg) + return 1 + + +def check_savpf(reqs): + offers = [m for _t, m in reqs if command_of(m) == "offer"] + print("ng requests: %d, OFFERs: %d" % (len(reqs), len(offers))) + if not offers: + return fail("no OFFER ng message captured -- harness/capture problem") + if any(WANT_PROTO in m for m in offers) and any(WANT_ICE in m for m in offers): + print("PASS: OFFER carries UDP/TLS/RTP/SAVPF + ICE=force") + return 0 + return fail("OFFER missing the callee transcode-to profile -> proxy asked " + "rtpengine for plain RTP/AVP -> WebRTC UA would 488 (#3902)") + + +def check_count(reqs, want, where): + cmds = [(m, command_of(m)) for _t, m in reqs] + offers = [m for m, c in cmds if c == "offer"] + target = [m for m, c in cmds if c == ("answer" if where == "answer" else "offer")] + savpf = [m for m in target if WANT_PROTO in m] + print("OFFERs: %d (want >= %d), %s carrying SAVPF: %d/%d" + % (len(offers), want, where, len(savpf), len(target))) + if len(offers) < want: + return fail("only %d/%d offers -> a re-INVITE did not re-anchor" + % (len(offers), want)) + if not target or len(savpf) != len(target): + return fail("%d/%d %s msg(s) lost the WebRTC profile -> #3902 present" + % (len(target) - len(savpf), len(target), where)) + print("PASS: re-anchored AND all %d %s msg(s) kept UDP/TLS/RTP/SAVPF" + % (len(target), where)) + return 0 + + +def check_failover(reqs, t0): + if t0 is None: + return fail("no rtp_relay_update MI request seen on the MI port") + msgs = [(command_of(m), WANT_PROTO in m) for t, m in reqs + if t >= t0 and command_of(m) in ("offer", "answer")] + print("MI fired at t=%.2fs, re-anchor offer/answer: %d" % (t0, len(msgs))) + if not msgs: + return fail("rtp_relay_update generated no re-anchor offer/answer") + if not any(s for _c, s in msgs): + return fail("failover re-anchor lost the WebRTC profile (all RTP/AVP) " + "-> WebRTC node would 488 the re-INVITE -> #3902 present") + print("PASS: failover re-anchor preserved UDP/TLS/RTP/SAVPF") + return 0 + + +def main(argv): + if len(argv) < 3: + print(__doc__) + return 2 + pcap, mode, rest = argv[1], argv[2], argv[3:] + ng_port, mi_port, pos, i = DEFAULT_NG_PORT, None, [], 0 + while i < len(rest): + if rest[i] == "--ng-port": + ng_port = int(rest[i + 1]); i += 2 + elif rest[i] == "--mi-port": + mi_port = int(rest[i + 1]); i += 2 + else: + pos.append(rest[i]); i += 1 + + print("GH-3902 rtp_relay ng checker mode=%s ng-port=%d" % (mode, ng_port)) + reqs = ng_requests(pcap, ng_port) + if mode == "savpf": + return check_savpf(reqs) + if mode == "count": + if not pos: + return fail("count mode needs ") + return check_count(reqs, int(pos[0]), pos[1] if len(pos) > 1 else "offer") + if mode == "failover": + if mi_port is None: + return fail("failover mode needs --mi-port N") + return check_failover(reqs, mi_time(pcap, mi_port)) + return fail("unknown mode %r" % mode) + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/rtp_relay/02.indialog-reinvite-hold-unhold/scripts/uac.xml b/rtp_relay/02.indialog-reinvite-hold-unhold/scripts/uac.xml new file mode 100644 index 0000000..dbc3ee6 --- /dev/null +++ b/rtp_relay/02.indialog-reinvite-hold-unhold/scripts/uac.xml @@ -0,0 +1,166 @@ + + + + + + + ;tag=[pid]SIPpTag00[call_number] + To: callee + Call-ID: [call_id] + CSeq: 1 INVITE + Contact: sip:caller@[local_ip]:[local_port] + Max-Forwards: 70 + Content-Type: application/sdp + Content-Length: [len] + + v=0 + o=caller 53655765 2353687637 IN IP[local_ip_type] [local_ip] + s=- + c=IN IP[media_ip_type] [media_ip] + t=0 0 + m=audio [media_port] RTP/AVP 8 + a=rtpmap:8 PCMA/8000 + a=sendrecv + ]]> + + + + + + + + ;tag=[pid]SIPpTag00[call_number] + To: callee [peer_tag_param] + Call-ID: [call_id] + CSeq: 1 ACK + Contact: sip:caller@[local_ip]:[local_port] + Max-Forwards: 70 + [routes] + Content-Length: 0 + + ]]> + + + + + + + ;tag=[pid]SIPpTag00[call_number] + To: callee [peer_tag_param] + Call-ID: [call_id] + CSeq: 2 INVITE + Contact: sip:caller@[local_ip]:[local_port] + Max-Forwards: 70 + [routes] + Content-Type: application/sdp + Content-Length: [len] + + v=0 + o=caller 53655765 2353687638 IN IP[local_ip_type] [local_ip] + s=- + c=IN IP[media_ip_type] 0.0.0.0 + t=0 0 + m=audio [media_port] RTP/AVP 8 + a=rtpmap:8 PCMA/8000 + a=sendonly + ]]> + + + + + + + ;tag=[pid]SIPpTag00[call_number] + To: callee [peer_tag_param] + Call-ID: [call_id] + CSeq: 2 ACK + Contact: sip:caller@[local_ip]:[local_port] + Max-Forwards: 70 + [routes] + Content-Length: 0 + + ]]> + + + + + + + ;tag=[pid]SIPpTag00[call_number] + To: callee [peer_tag_param] + Call-ID: [call_id] + CSeq: 3 INVITE + Contact: sip:caller@[local_ip]:[local_port] + Max-Forwards: 70 + [routes] + Content-Type: application/sdp + Content-Length: [len] + + v=0 + o=caller 53655765 2353687639 IN IP[local_ip_type] [local_ip] + s=- + c=IN IP[media_ip_type] [media_ip] + t=0 0 + m=audio [media_port] RTP/AVP 8 + a=rtpmap:8 PCMA/8000 + a=sendrecv + ]]> + + + + + + + ;tag=[pid]SIPpTag00[call_number] + To: callee [peer_tag_param] + Call-ID: [call_id] + CSeq: 3 ACK + Contact: sip:caller@[local_ip]:[local_port] + Max-Forwards: 70 + [routes] + Content-Length: 0 + + ]]> + + + + + + ;tag=[pid]SIPpTag00[call_number] + To: callee [peer_tag_param] + Call-ID: [call_id] + CSeq: 4 BYE + Contact: sip:caller@[local_ip]:[local_port] + Max-Forwards: 70 + [routes] + Content-Length: 0 + + ]]> + + + + + diff --git a/rtp_relay/02.indialog-reinvite-hold-unhold/scripts/uas.xml b/rtp_relay/02.indialog-reinvite-hold-unhold/scripts/uas.xml new file mode 100644 index 0000000..c876609 --- /dev/null +++ b/rtp_relay/02.indialog-reinvite-hold-unhold/scripts/uas.xml @@ -0,0 +1,119 @@ + + + + + + + + + + Content-Length: 0 + + ]]> + + + + + Content-Type: application/sdp + Content-Length: [len] + + v=0 + o=callee 7766554 7766554 IN IP[local_ip_type] [local_ip] + s=- + c=IN IP[media_ip_type] [media_ip] + t=0 0 + m=audio [media_port] RTP/AVP 8 + a=rtpmap:8 PCMA/8000 + a=sendrecv + ]]> + + + + + + + + + Content-Type: application/sdp + Content-Length: [len] + + v=0 + o=callee 7766554 7766555 IN IP[local_ip_type] [local_ip] + s=- + c=IN IP[media_ip_type] [media_ip] + t=0 0 + m=audio [media_port] RTP/AVP 8 + a=rtpmap:8 PCMA/8000 + a=recvonly + ]]> + + + + + + + + Content-Type: application/sdp + Content-Length: [len] + + v=0 + o=callee 7766554 7766556 IN IP[local_ip_type] [local_ip] + s=- + c=IN IP[media_ip_type] [media_ip] + t=0 0 + m=audio [media_port] RTP/AVP 8 + a=rtpmap:8 PCMA/8000 + a=sendrecv + ]]> + + + + + + + Content-Length: 0 + + ]]> + + + + diff --git a/rtp_relay/03.update-failover-reanchor/opensips.cfg b/rtp_relay/03.update-failover-reanchor/opensips.cfg new file mode 100644 index 0000000..402140b --- /dev/null +++ b/rtp_relay/03.update-failover-reanchor/opensips.cfg @@ -0,0 +1,77 @@ +# +# GH-3902 rtp_relay -- SIP -> WebRTC transcoding proxy + rtp_relay_update. +# As 01/02 plus an mi_datagram socket so the test fires rtp_relay_update -- the +# node-failover path where OpenSIPS GENERATES the re-INVITE itself (what the +# maintainer asked about), re-anchoring the stored callee SDP. Affected OpenSIPS +# drops the callee WebRTC profile (UDP/TLS/RTP/SAVPF + ICE=force) on the re-anchor. +# +log_level=4 +stderror_enabled=yes + +udp_workers=4 + +socket=udp:192.168.52.1:5060 + +####### Modules ####### +mpath="/usr/lib/x86_64-linux-gnu/opensips/modules/" +loadmodule "proto_udp.so" +loadmodule "tm.so" +loadmodule "sl.so" +loadmodule "rr.so" +loadmodule "maxfwd.so" +loadmodule "sipmsgops.so" +loadmodule "dialog.so" +loadmodule "rtpengine.so" +loadmodule "rtp_relay.so" +loadmodule "mi_datagram.so" + +modparam("mi_datagram", "socket_name", "udp:192.168.52.1:8088") +modparam("rtpengine", "rtpengine_sock", "udp:192.168.52.4:22222") + +####### Routing ####### +route{ + if (!mf_process_maxfwd_header(10)) { + sl_send_reply(483,"Too Many Hops"); + exit; + } + + if (has_totag()) { + if (loose_route()) { + xlog("L_NOTICE","SEQ $rm cseq=$cs\n"); + t_relay(); + exit; + } + sl_send_reply(404,"Not here"); + exit; + } + + if (is_method("CANCEL")) { + t_relay(); + exit; + } + + if (is_method("INVITE")) { + xlog("L_NOTICE","INITIAL INVITE -- caller(RTP/AVP) leg + engage\n"); + create_dialog(); + + $rtp_relay(flags) = "replace-origin replace-session-connection"; + $rtp_relay(type) = "RTP/AVP"; + $rtp_relay(peer) = "ICE=remove"; + + rtp_relay_engage("rtpengine"); + record_route(); + $du = "sip:192.168.52.3:5080"; + t_on_branch("webrtc"); + t_relay(); + exit; + } + + sl_send_reply(405,"Method Not Allowed"); +} + +branch_route[webrtc] { + xlog("L_NOTICE","BRANCH webrtc -- provisioning callee WebRTC profile\n"); + $rtp_relay(flags) = "replace-origin replace-session-connection"; + $rtp_relay(type) = "UDP/TLS/RTP/SAVPF"; + $rtp_relay(peer) = "ICE=force rtcp-mux-require SDES-off"; +} diff --git a/rtp_relay/03.update-failover-reanchor/scenario.yml b/rtp_relay/03.update-failover-reanchor/scenario.yml new file mode 100644 index 0000000..03d2c8b --- /dev/null +++ b/rtp_relay/03.update-failover-reanchor/scenario.yml @@ -0,0 +1,115 @@ +--- +# GH-3902 rtp_relay -- rtp_relay_update node-failover re-anchor. +# +# This is the trigger the maintainer (@razvancrainea) specifically asked about: +# the re-INVITE is NOT UAC-initiated -- OpenSIPS generates it itself from the +# rtp_relay_update MI command (rtpengine node failover / watchdog), re-offering +# the call onto a surviving node. We establish a transcoding call, keep it up, +# fire rtp_relay_update over mi_datagram (udp:8088), and let OpenSIPS re-INVITE +# the caller then the callee. The checker splits the captured ng stream at the MI +# packet and checks the post-failover re-anchor offer/answer still carries the +# callee WebRTC profile (UDP/TLS/RTP/SAVPF). On affected OpenSIPS the re-anchor +# is plain RTP/AVP (RED -> a real WebRTC node would 488 and the call drops on +# failover); with the fix it preserves the profile (GREEN). +# +# NB: single rtpengine node -- the MI re-anchors onto the same node, which is +# enough to exercise the self-generated re-INVITE / profile-carry path. A real +# node-down event is not required. + +timeout: 360 + +tasks: + - name: rtpengine + type: generic + image: {{ rtpengine_image }} + ip: {{ rtpengine_ip }} + daemon: true + stop_timeout: 3 + entrypoint: sh + # debian:slim base -> install the official Debian rtpengine-daemon package + # (sipwise upstream; userspace-only, no kernel dkms). policy-rc.d keeps the + # package postinst from starting a service; iproute2 provides `ss` for the + # readiness healthcheck below. Then run rtpengine in the foreground. + args: ["-c", "echo 'exit 101' > /usr/sbin/policy-rc.d && chmod +x /usr/sbin/policy-rc.d && apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends rtpengine-daemon iproute2 && exec rtpengine --table=-1 --interface={{ rtpengine_ip }} --listen-ng={{ rtpengine_ip }}:{{ rtpengine_ng_port }} --foreground --log-stderr"] + # healthy once the ng socket is bound, so dependents gate on readiness (not a + # fixed sleep) and the apt-install delay cannot race the proxy startup ping. + healthcheck: + test: "ss -lnu | grep -q :{{ rtpengine_ng_port }}" + interval: 2000000000 + timeout: 2000000000 + retries: 120 + + # host-networked sniffer on osbr0; captures BOTH the ng port and the MI + # datagram port so the checker can locate the rtp_relay_update packet and split + # the stream at it. + - name: ng capture + type: generic + image: {{ capture_image }} + network: host + daemon: true + stop_timeout: 2 + volumes: + - caps + entrypoint: sh + args: ["-c", "tcpdump -i osbr0 -n -s0 -U -w /caps/ng.pcap 'udp and (port {{ rtpengine_ng_port }} or port 8088)'"] + require: + healthy: rtpengine + + - name: OpenSIPS + type: opensips + require: + - healthy: rtpengine + - started: + task: ng capture + wait: 0.5 + + # both legs run long enough to receive the server-generated re-INVITE. + - name: SIPP UAS + type: uas-sipp + config_file: scripts/uas.xml + duration: 12000 + require: OpenSIPS + + - name: SIPP UAC + type: uac-sipp + config_file: scripts/uac.xml + remote: {{ uas_ip }}:{{ uas_port }} + caller: caller + calls: 1 + duration: 12000 + require: + - started: + task: SIPP UAS + wait: 0.5 + - after: + task: OpenSIPS + wait: 0.5 + + # fire the node-failover re-anchor once the call is confirmed and SDP stored. + - name: MI rtp_relay_update + type: opensips-mi + mi_type: datagram + mi_port: 8088 + args: rtp_relay_update + require: + started: + task: SIPP UAC + wait: 3 + + # checker: after the MI packet, a re-anchor offer/answer must keep SAVPF. + - name: ng checker + type: generic + image: {{ checker_image }} + volumes: + - caps + entrypoint: python3 + args: ["/home/scripts/ng_checker.py", "/caps/ng.pcap", "failover", "--ng-port", "{{ rtpengine_ng_port }}", "--mi-port", "8088"] + require: + after: + task: MI rtp_relay_update + wait: 4 + +volumes: + caps: + bind: /caps + mode: rw diff --git a/rtp_relay/03.update-failover-reanchor/scripts/ng_checker.py b/rtp_relay/03.update-failover-reanchor/scripts/ng_checker.py new file mode 100644 index 0000000..13043f5 --- /dev/null +++ b/rtp_relay/03.update-failover-reanchor/scripts/ng_checker.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +"""GH-3902 rtp_relay checker -- assert what OpenSIPS ASKS rtpengine. + +Dependency-free (stdlib only, no tshark/scapy): parses a tcpdump pcap and decodes +the rtpengine "ng" bencode control messages. The bug (#3902): the callee/WebRTC +profile provisioned in the branch_route is not linked into sess->legs[CALLEE] +before the offer, so rtp_relay asks rtpengine for plain RTP/AVP -- losing +UDP/TLS/RTP/SAVPF + ICE=force. We read the ng OFFER/ANSWER on udp: and +assert the profile is present. + + ng_checker.py savpf [--ng-port N] + PASS iff some OFFER carries UDP/TLS/RTP/SAVPF + ICE=force. + ng_checker.py count [offer|answer] [--ng-port N] + PASS iff >= min_offers OFFERs AND every msg of that axis keeps SAVPF. + ng_checker.py failover --mi-port M [--ng-port N] + PASS iff a re-anchor OFFER/ANSWER after the rtp_relay_update MI keeps SAVPF. + +Exit 0 = PASS, non-zero = FAIL. +""" +import struct +import sys + +WANT_PROTO = b"UDP/TLS/RTP/SAVPF" +WANT_ICE = b"force" +DEFAULT_NG_PORT = 22222 + + +def read_pcap(path): + """Yield (ts_relative, linktype, frame) per record of a classic pcap.""" + with open(path, "rb") as f: + gh = f.read(24) + if len(gh) < 24: + return + magic = gh[:4] + if magic in (b"\xd4\xc3\xb2\xa1", b"\x4d\x3c\xb2\xa1"): + endian = "<" + elif magic in (b"\xa1\xb2\xc3\xd4", b"\xa1\xb2\x3c\x4d"): + endian = ">" + else: + raise SystemExit("not a classic pcap (magic=%r); use tcpdump default " + "output, not pcapng" % magic) + nanos = magic in (b"\x4d\x3c\xb2\xa1", b"\xa1\xb2\x3c\x4d") + linktype = struct.unpack(endian + "I", gh[20:24])[0] + t0 = None + while True: + rh = f.read(16) + if len(rh) < 16: + return + ts_sec, ts_frac, caplen, _orig = struct.unpack(endian + "IIII", rh) + data = f.read(caplen) + if len(data) < caplen: + return + ts = ts_sec + (ts_frac / 1e9 if nanos else ts_frac / 1e6) + if t0 is None: + t0 = ts + yield (ts - t0, linktype, data) + + +def udp_payloads(path): + """Yield (ts, dst_port, payload) for each IPv4/UDP packet.""" + for ts, linktype, data in read_pcap(path): + if linktype == 1: # Ethernet (tcpdump -i osbr0) + if len(data) < 14 or struct.unpack("!H", data[12:14])[0] != 0x0800: + continue + off = 14 + elif linktype == 113: # Linux cooked + if len(data) < 16 or struct.unpack("!H", data[14:16])[0] != 0x0800: + continue + off = 16 + else: + continue + ip = data[off:] + if len(ip) < 20 or (ip[0] >> 4) != 4 or ip[9] != 17: # IPv4 + UDP + continue + udp = ip[(ip[0] & 0x0F) * 4:] + if len(udp) < 8: + continue + yield (ts, struct.unpack("!H", udp[2:4])[0], udp[8:]) + + +def command_of(msg): + """Extract the bencode 7:command value (e.g. 'offer'/'answer') or None.""" + i = msg.find(b"7:command") + if i < 0: + return None + colon = msg.find(b":", i + 9) + if colon < 0: + return None + try: + n = int(msg[i + 9:colon]) + except ValueError: + return None + return msg[colon + 1:colon + 1 + n].decode("latin1") + + +def ng_requests(path, ng_port): + """[(ts, payload)] for ng requests sent TO rtpengine.""" + return [(ts, p) for ts, dport, p in udp_payloads(path) + if dport == ng_port and b"7:command" in p] + + +def mi_time(path, mi_port): + """Relative time of the rtp_relay_update MI request, or None.""" + for ts, dport, p in udp_payloads(path): + if dport == mi_port and b"rtp_relay_update" in p: + return ts + return None + + +def fail(msg): + print("FAIL: " + msg) + return 1 + + +def check_savpf(reqs): + offers = [m for _t, m in reqs if command_of(m) == "offer"] + print("ng requests: %d, OFFERs: %d" % (len(reqs), len(offers))) + if not offers: + return fail("no OFFER ng message captured -- harness/capture problem") + if any(WANT_PROTO in m for m in offers) and any(WANT_ICE in m for m in offers): + print("PASS: OFFER carries UDP/TLS/RTP/SAVPF + ICE=force") + return 0 + return fail("OFFER missing the callee transcode-to profile -> proxy asked " + "rtpengine for plain RTP/AVP -> WebRTC UA would 488 (#3902)") + + +def check_count(reqs, want, where): + cmds = [(m, command_of(m)) for _t, m in reqs] + offers = [m for m, c in cmds if c == "offer"] + target = [m for m, c in cmds if c == ("answer" if where == "answer" else "offer")] + savpf = [m for m in target if WANT_PROTO in m] + print("OFFERs: %d (want >= %d), %s carrying SAVPF: %d/%d" + % (len(offers), want, where, len(savpf), len(target))) + if len(offers) < want: + return fail("only %d/%d offers -> a re-INVITE did not re-anchor" + % (len(offers), want)) + if not target or len(savpf) != len(target): + return fail("%d/%d %s msg(s) lost the WebRTC profile -> #3902 present" + % (len(target) - len(savpf), len(target), where)) + print("PASS: re-anchored AND all %d %s msg(s) kept UDP/TLS/RTP/SAVPF" + % (len(target), where)) + return 0 + + +def check_failover(reqs, t0): + if t0 is None: + return fail("no rtp_relay_update MI request seen on the MI port") + msgs = [(command_of(m), WANT_PROTO in m) for t, m in reqs + if t >= t0 and command_of(m) in ("offer", "answer")] + print("MI fired at t=%.2fs, re-anchor offer/answer: %d" % (t0, len(msgs))) + if not msgs: + return fail("rtp_relay_update generated no re-anchor offer/answer") + if not any(s for _c, s in msgs): + return fail("failover re-anchor lost the WebRTC profile (all RTP/AVP) " + "-> WebRTC node would 488 the re-INVITE -> #3902 present") + print("PASS: failover re-anchor preserved UDP/TLS/RTP/SAVPF") + return 0 + + +def main(argv): + if len(argv) < 3: + print(__doc__) + return 2 + pcap, mode, rest = argv[1], argv[2], argv[3:] + ng_port, mi_port, pos, i = DEFAULT_NG_PORT, None, [], 0 + while i < len(rest): + if rest[i] == "--ng-port": + ng_port = int(rest[i + 1]); i += 2 + elif rest[i] == "--mi-port": + mi_port = int(rest[i + 1]); i += 2 + else: + pos.append(rest[i]); i += 1 + + print("GH-3902 rtp_relay ng checker mode=%s ng-port=%d" % (mode, ng_port)) + reqs = ng_requests(pcap, ng_port) + if mode == "savpf": + return check_savpf(reqs) + if mode == "count": + if not pos: + return fail("count mode needs ") + return check_count(reqs, int(pos[0]), pos[1] if len(pos) > 1 else "offer") + if mode == "failover": + if mi_port is None: + return fail("failover mode needs --mi-port N") + return check_failover(reqs, mi_time(pcap, mi_port)) + return fail("unknown mode %r" % mode) + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/rtp_relay/03.update-failover-reanchor/scripts/uac.xml b/rtp_relay/03.update-failover-reanchor/scripts/uac.xml new file mode 100644 index 0000000..6b2e0ff --- /dev/null +++ b/rtp_relay/03.update-failover-reanchor/scripts/uac.xml @@ -0,0 +1,102 @@ + + + + + + + ;tag=[pid]SIPpTag00[call_number] + To: callee + Call-ID: [call_id] + CSeq: 1 INVITE + Contact: sip:caller@[local_ip]:[local_port] + Max-Forwards: 70 + Content-Type: application/sdp + Content-Length: [len] + + v=0 + o=caller 53655765 2353687637 IN IP[local_ip_type] [local_ip] + s=- + c=IN IP[media_ip_type] [media_ip] + t=0 0 + m=audio [media_port] RTP/AVP 8 + a=rtpmap:8 PCMA/8000 + a=sendrecv + ]]> + + + + + + + + ;tag=[pid]SIPpTag00[call_number] + To: callee [peer_tag_param] + Call-ID: [call_id] + CSeq: 1 ACK + Contact: sip:caller@[local_ip]:[local_port] + Max-Forwards: 70 + [routes] + Content-Length: 0 + + ]]> + + + + + + + + Content-Type: application/sdp + Content-Length: [len] + + v=0 + o=caller 53655765 2353687640 IN IP[local_ip_type] [local_ip] + s=- + c=IN IP[media_ip_type] [media_ip] + t=0 0 + m=audio [media_port] RTP/AVP 8 + a=rtpmap:8 PCMA/8000 + a=sendrecv + ]]> + + + + + + + + + ;tag=[pid]SIPpTag00[call_number] + To: callee [peer_tag_param] + Call-ID: [call_id] + CSeq: 2 BYE + Contact: sip:caller@[local_ip]:[local_port] + Max-Forwards: 70 + [routes] + Content-Length: 0 + + ]]> + + + + diff --git a/rtp_relay/03.update-failover-reanchor/scripts/uas.xml b/rtp_relay/03.update-failover-reanchor/scripts/uas.xml new file mode 100644 index 0000000..db6cb46 --- /dev/null +++ b/rtp_relay/03.update-failover-reanchor/scripts/uas.xml @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + Content-Type: application/sdp + Content-Length: [len] + + v=0 + o=callee 7766554 7766554 IN IP[local_ip_type] [local_ip] + s=- + c=IN IP[media_ip_type] [media_ip] + t=0 0 + m=audio [media_port] RTP/AVP 8 + a=rtpmap:8 PCMA/8000 + a=sendrecv + ]]> + + + + + + + + + + Content-Type: application/sdp + Content-Length: [len] + + v=0 + o=callee 7766554 7766555 IN IP[local_ip_type] [local_ip] + s=- + c=IN IP[media_ip_type] [media_ip] + t=0 0 + m=audio [media_port] RTP/AVP 8 + a=rtpmap:8 PCMA/8000 + a=sendrecv + ]]> + + + + + + + + + + + diff --git a/rtp_relay/04.late-negotiation/opensips.cfg b/rtp_relay/04.late-negotiation/opensips.cfg new file mode 100644 index 0000000..ca81773 --- /dev/null +++ b/rtp_relay/04.late-negotiation/opensips.cfg @@ -0,0 +1,79 @@ +# +# GH-3902 rtp_relay -- SIP -> WebRTC transcoding proxy. +# Caller leg is plain RTP/AVP (request route); the callee WebRTC profile +# (UDP/TLS/RTP/SAVPF + ICE=force) is set in branch_route[webrtc]. Affected +# OpenSIPS never links that branch leg into sess->legs[CALLEE] before the offer, +# so rtp_relay asks rtpengine for plain RTP/AVP -- the #3902 bug. The checker +# inspects the ng OFFER/ANSWER OpenSIPS sends on the rtpengine ng port. +# +log_level=4 # DBG: rtp_relay_offer prints type=[...] peer-flags=[...] +stderror_enabled=yes + +udp_workers=4 + +socket=udp:192.168.52.1:5060 + +####### Modules ####### +mpath="/usr/lib/x86_64-linux-gnu/opensips/modules/" +loadmodule "proto_udp.so" +loadmodule "tm.so" +loadmodule "sl.so" +loadmodule "rr.so" +loadmodule "maxfwd.so" +loadmodule "sipmsgops.so" +loadmodule "dialog.so" +loadmodule "rtpengine.so" +loadmodule "rtp_relay.so" + +modparam("rtpengine", "rtpengine_sock", "udp:192.168.52.4:22222") + +####### Routing ####### +route{ + if (!mf_process_maxfwd_header(10)) { + sl_send_reply(483,"Too Many Hops"); + exit; + } + + if (has_totag()) { + if (loose_route()) { + xlog("L_NOTICE","SEQ $rm cseq=$cs\n"); + t_relay(); + exit; + } + sl_send_reply(404,"Not here"); + exit; + } + + if (is_method("CANCEL")) { + t_relay(); + exit; + } + + if (is_method("INVITE")) { + xlog("L_NOTICE","INITIAL INVITE -- caller(RTP/AVP) leg + engage\n"); + create_dialog(); + + # ---- caller (inbound) leg: plain SIP, RTP/AVP ---- + $rtp_relay(flags) = "replace-origin replace-session-connection"; + $rtp_relay(type) = "RTP/AVP"; + $rtp_relay(peer) = "ICE=remove"; + + rtp_relay_engage("rtpengine"); + record_route(); + $du = "sip:192.168.52.3:5080"; + t_on_branch("webrtc"); + t_relay(); + exit; + } + + sl_send_reply(405,"Method Not Allowed"); +} + +# ---- callee (outbound) leg: WebRTC, provisioned at the branch index ---- +# This is the leg that affected OpenSIPS fails to link into sess->legs[CALLEE]. +branch_route[webrtc] { + xlog("L_NOTICE","BRANCH webrtc -- provisioning callee WebRTC profile\n"); + $rtp_relay(flags) = "replace-origin replace-session-connection"; + $rtp_relay(type) = "UDP/TLS/RTP/SAVPF"; + $rtp_relay(peer) = "ICE=force rtcp-mux-require SDES-off"; +} diff --git a/rtp_relay/04.late-negotiation/scenario.yml b/rtp_relay/04.late-negotiation/scenario.yml new file mode 100644 index 0000000..679297f --- /dev/null +++ b/rtp_relay/04.late-negotiation/scenario.yml @@ -0,0 +1,93 @@ +--- +# GH-3902 rtp_relay -- no-SDP late negotiation (delayed offer, answer axis). +# +# The caller sends an INVITE with NO SDP, so the callee offers SDP in its 200 OK +# and the caller answers in the ACK. rtp_relay marks the session "late" and +# emits the rtpengine OFFER on the RESPONSE path -- the OTHER initial_cb branch. +# Because the callee is the offerer, offer/answer roles invert: the +# branch-provisioned callee WebRTC profile must ride the leg=caller ANSWER. The +# checker therefore checks the ANSWER axis. On affected OpenSIPS the answer drops +# the profile (RED); the fix that links the callee leg in BOTH initial_cb +# branches preserves it (GREEN). This is the path that needs the full fix, not +# just the REQUEST_FWDED-only minimal variant. + +timeout: 300 + +tasks: + - name: rtpengine + type: generic + image: {{ rtpengine_image }} + ip: {{ rtpengine_ip }} + daemon: true + stop_timeout: 3 + entrypoint: sh + # debian:slim base -> install the official Debian rtpengine-daemon package + # (sipwise upstream; userspace-only, no kernel dkms). policy-rc.d keeps the + # package postinst from starting a service; iproute2 provides `ss` for the + # readiness healthcheck below. Then run rtpengine in the foreground. + args: ["-c", "echo 'exit 101' > /usr/sbin/policy-rc.d && chmod +x /usr/sbin/policy-rc.d && apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends rtpengine-daemon iproute2 && exec rtpengine --table=-1 --interface={{ rtpengine_ip }} --listen-ng={{ rtpengine_ip }}:{{ rtpengine_ng_port }} --foreground --log-stderr"] + # healthy once the ng socket is bound, so dependents gate on readiness (not a + # fixed sleep) and the apt-install delay cannot race the proxy startup ping. + healthcheck: + test: "ss -lnu | grep -q :{{ rtpengine_ng_port }}" + interval: 2000000000 + timeout: 2000000000 + retries: 120 + + - name: ng capture + type: generic + image: {{ capture_image }} + network: host + daemon: true + stop_timeout: 2 + volumes: + - caps + entrypoint: sh + args: ["-c", "tcpdump -i osbr0 -n -s0 -U -w /caps/ng.pcap udp port {{ rtpengine_ng_port }}"] + require: + healthy: rtpengine + + - name: OpenSIPS + type: opensips + require: + - healthy: rtpengine + - started: + task: ng capture + wait: 0.5 + + - name: SIPP UAS + type: uas-sipp + config_file: scripts/uas.xml + require: OpenSIPS + + - name: SIPP UAC + type: uac-sipp + config_file: scripts/uac.xml + remote: {{ uas_ip }}:{{ uas_port }} + caller: caller + calls: 1 + require: + - started: + task: SIPP UAS + wait: 0.5 + - after: + task: OpenSIPS + wait: 0.5 + + # ng checker: at least 1 OFFER, and every ANSWER keeps UDP/TLS/RTP/SAVPF. + - name: ng checker + type: generic + image: {{ checker_image }} + volumes: + - caps + entrypoint: python3 + args: ["/home/scripts/ng_checker.py", "/caps/ng.pcap", "count", "1", "answer", "--ng-port", "{{ rtpengine_ng_port }}"] + require: + after: + task: SIPP UAC + wait: 2 + +volumes: + caps: + bind: /caps + mode: rw diff --git a/rtp_relay/04.late-negotiation/scripts/ng_checker.py b/rtp_relay/04.late-negotiation/scripts/ng_checker.py new file mode 100644 index 0000000..13043f5 --- /dev/null +++ b/rtp_relay/04.late-negotiation/scripts/ng_checker.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +"""GH-3902 rtp_relay checker -- assert what OpenSIPS ASKS rtpengine. + +Dependency-free (stdlib only, no tshark/scapy): parses a tcpdump pcap and decodes +the rtpengine "ng" bencode control messages. The bug (#3902): the callee/WebRTC +profile provisioned in the branch_route is not linked into sess->legs[CALLEE] +before the offer, so rtp_relay asks rtpengine for plain RTP/AVP -- losing +UDP/TLS/RTP/SAVPF + ICE=force. We read the ng OFFER/ANSWER on udp: and +assert the profile is present. + + ng_checker.py savpf [--ng-port N] + PASS iff some OFFER carries UDP/TLS/RTP/SAVPF + ICE=force. + ng_checker.py count [offer|answer] [--ng-port N] + PASS iff >= min_offers OFFERs AND every msg of that axis keeps SAVPF. + ng_checker.py failover --mi-port M [--ng-port N] + PASS iff a re-anchor OFFER/ANSWER after the rtp_relay_update MI keeps SAVPF. + +Exit 0 = PASS, non-zero = FAIL. +""" +import struct +import sys + +WANT_PROTO = b"UDP/TLS/RTP/SAVPF" +WANT_ICE = b"force" +DEFAULT_NG_PORT = 22222 + + +def read_pcap(path): + """Yield (ts_relative, linktype, frame) per record of a classic pcap.""" + with open(path, "rb") as f: + gh = f.read(24) + if len(gh) < 24: + return + magic = gh[:4] + if magic in (b"\xd4\xc3\xb2\xa1", b"\x4d\x3c\xb2\xa1"): + endian = "<" + elif magic in (b"\xa1\xb2\xc3\xd4", b"\xa1\xb2\x3c\x4d"): + endian = ">" + else: + raise SystemExit("not a classic pcap (magic=%r); use tcpdump default " + "output, not pcapng" % magic) + nanos = magic in (b"\x4d\x3c\xb2\xa1", b"\xa1\xb2\x3c\x4d") + linktype = struct.unpack(endian + "I", gh[20:24])[0] + t0 = None + while True: + rh = f.read(16) + if len(rh) < 16: + return + ts_sec, ts_frac, caplen, _orig = struct.unpack(endian + "IIII", rh) + data = f.read(caplen) + if len(data) < caplen: + return + ts = ts_sec + (ts_frac / 1e9 if nanos else ts_frac / 1e6) + if t0 is None: + t0 = ts + yield (ts - t0, linktype, data) + + +def udp_payloads(path): + """Yield (ts, dst_port, payload) for each IPv4/UDP packet.""" + for ts, linktype, data in read_pcap(path): + if linktype == 1: # Ethernet (tcpdump -i osbr0) + if len(data) < 14 or struct.unpack("!H", data[12:14])[0] != 0x0800: + continue + off = 14 + elif linktype == 113: # Linux cooked + if len(data) < 16 or struct.unpack("!H", data[14:16])[0] != 0x0800: + continue + off = 16 + else: + continue + ip = data[off:] + if len(ip) < 20 or (ip[0] >> 4) != 4 or ip[9] != 17: # IPv4 + UDP + continue + udp = ip[(ip[0] & 0x0F) * 4:] + if len(udp) < 8: + continue + yield (ts, struct.unpack("!H", udp[2:4])[0], udp[8:]) + + +def command_of(msg): + """Extract the bencode 7:command value (e.g. 'offer'/'answer') or None.""" + i = msg.find(b"7:command") + if i < 0: + return None + colon = msg.find(b":", i + 9) + if colon < 0: + return None + try: + n = int(msg[i + 9:colon]) + except ValueError: + return None + return msg[colon + 1:colon + 1 + n].decode("latin1") + + +def ng_requests(path, ng_port): + """[(ts, payload)] for ng requests sent TO rtpengine.""" + return [(ts, p) for ts, dport, p in udp_payloads(path) + if dport == ng_port and b"7:command" in p] + + +def mi_time(path, mi_port): + """Relative time of the rtp_relay_update MI request, or None.""" + for ts, dport, p in udp_payloads(path): + if dport == mi_port and b"rtp_relay_update" in p: + return ts + return None + + +def fail(msg): + print("FAIL: " + msg) + return 1 + + +def check_savpf(reqs): + offers = [m for _t, m in reqs if command_of(m) == "offer"] + print("ng requests: %d, OFFERs: %d" % (len(reqs), len(offers))) + if not offers: + return fail("no OFFER ng message captured -- harness/capture problem") + if any(WANT_PROTO in m for m in offers) and any(WANT_ICE in m for m in offers): + print("PASS: OFFER carries UDP/TLS/RTP/SAVPF + ICE=force") + return 0 + return fail("OFFER missing the callee transcode-to profile -> proxy asked " + "rtpengine for plain RTP/AVP -> WebRTC UA would 488 (#3902)") + + +def check_count(reqs, want, where): + cmds = [(m, command_of(m)) for _t, m in reqs] + offers = [m for m, c in cmds if c == "offer"] + target = [m for m, c in cmds if c == ("answer" if where == "answer" else "offer")] + savpf = [m for m in target if WANT_PROTO in m] + print("OFFERs: %d (want >= %d), %s carrying SAVPF: %d/%d" + % (len(offers), want, where, len(savpf), len(target))) + if len(offers) < want: + return fail("only %d/%d offers -> a re-INVITE did not re-anchor" + % (len(offers), want)) + if not target or len(savpf) != len(target): + return fail("%d/%d %s msg(s) lost the WebRTC profile -> #3902 present" + % (len(target) - len(savpf), len(target), where)) + print("PASS: re-anchored AND all %d %s msg(s) kept UDP/TLS/RTP/SAVPF" + % (len(target), where)) + return 0 + + +def check_failover(reqs, t0): + if t0 is None: + return fail("no rtp_relay_update MI request seen on the MI port") + msgs = [(command_of(m), WANT_PROTO in m) for t, m in reqs + if t >= t0 and command_of(m) in ("offer", "answer")] + print("MI fired at t=%.2fs, re-anchor offer/answer: %d" % (t0, len(msgs))) + if not msgs: + return fail("rtp_relay_update generated no re-anchor offer/answer") + if not any(s for _c, s in msgs): + return fail("failover re-anchor lost the WebRTC profile (all RTP/AVP) " + "-> WebRTC node would 488 the re-INVITE -> #3902 present") + print("PASS: failover re-anchor preserved UDP/TLS/RTP/SAVPF") + return 0 + + +def main(argv): + if len(argv) < 3: + print(__doc__) + return 2 + pcap, mode, rest = argv[1], argv[2], argv[3:] + ng_port, mi_port, pos, i = DEFAULT_NG_PORT, None, [], 0 + while i < len(rest): + if rest[i] == "--ng-port": + ng_port = int(rest[i + 1]); i += 2 + elif rest[i] == "--mi-port": + mi_port = int(rest[i + 1]); i += 2 + else: + pos.append(rest[i]); i += 1 + + print("GH-3902 rtp_relay ng checker mode=%s ng-port=%d" % (mode, ng_port)) + reqs = ng_requests(pcap, ng_port) + if mode == "savpf": + return check_savpf(reqs) + if mode == "count": + if not pos: + return fail("count mode needs ") + return check_count(reqs, int(pos[0]), pos[1] if len(pos) > 1 else "offer") + if mode == "failover": + if mi_port is None: + return fail("failover mode needs --mi-port N") + return check_failover(reqs, mi_time(pcap, mi_port)) + return fail("unknown mode %r" % mode) + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/rtp_relay/04.late-negotiation/scripts/uac.xml b/rtp_relay/04.late-negotiation/scripts/uac.xml new file mode 100644 index 0000000..b2012dc --- /dev/null +++ b/rtp_relay/04.late-negotiation/scripts/uac.xml @@ -0,0 +1,75 @@ + + + + + + + ;tag=[pid]SIPpTag00[call_number] + To: callee + Call-ID: [call_id] + CSeq: 1 INVITE + Contact: sip:caller@[local_ip]:[local_port] + Max-Forwards: 70 + Content-Length: 0 + + ]]> + + + + + + + + + ;tag=[pid]SIPpTag00[call_number] + To: callee [peer_tag_param] + Call-ID: [call_id] + CSeq: 1 ACK + Contact: sip:caller@[local_ip]:[local_port] + Max-Forwards: 70 + [routes] + Content-Type: application/sdp + Content-Length: [len] + + v=0 + o=caller 53655765 2353687637 IN IP[local_ip_type] [local_ip] + s=- + c=IN IP[media_ip_type] [media_ip] + t=0 0 + m=audio [media_port] RTP/AVP 8 + a=rtpmap:8 PCMA/8000 + a=sendrecv + ]]> + + + + + + ;tag=[pid]SIPpTag00[call_number] + To: callee [peer_tag_param] + Call-ID: [call_id] + CSeq: 2 BYE + Contact: sip:caller@[local_ip]:[local_port] + Max-Forwards: 70 + [routes] + Content-Length: 0 + + ]]> + + + + diff --git a/rtp_relay/04.late-negotiation/scripts/uas.xml b/rtp_relay/04.late-negotiation/scripts/uas.xml new file mode 100644 index 0000000..83a3e06 --- /dev/null +++ b/rtp_relay/04.late-negotiation/scripts/uas.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + Content-Type: application/sdp + Content-Length: [len] + + v=0 + o=callee 7766554 7766554 IN IP[local_ip_type] [local_ip] + s=- + c=IN IP[media_ip_type] [media_ip] + t=0 0 + m=audio [media_port] RTP/AVP 8 + a=rtpmap:8 PCMA/8000 + a=sendrecv + ]]> + + + + + + + + + + + diff --git a/rtp_relay/config.yml b/rtp_relay/config.yml new file mode 120000 index 0000000..1555250 --- /dev/null +++ b/rtp_relay/config.yml @@ -0,0 +1 @@ +../config.yml \ No newline at end of file diff --git a/rtp_relay/defines.yml b/rtp_relay/defines.yml new file mode 120000 index 0000000..a3831d9 --- /dev/null +++ b/rtp_relay/defines.yml @@ -0,0 +1 @@ +../defines.yml \ No newline at end of file diff --git a/run.yml b/run.yml index 469eeb2..781df3f 100644 --- a/run.yml +++ b/run.yml @@ -12,6 +12,7 @@ tests: - presence - record-route - registration + - rtp_relay - startup - stir-shaken - topology-hiding