From b5c1968f69b77b792e76a440bec855e55fac4a07 Mon Sep 17 00:00:00 2001 From: Stackie Jia Date: Mon, 3 Aug 2026 18:49:46 +0800 Subject: [PATCH 1/4] feat(rtsp): add ZTE NAT traversal mode --- docs/en/reference/configuration.md | 11 + docs/reference/configuration.md | 11 + e2e/helpers/__init__.py | 2 + e2e/helpers/mock_rtsp.py | 130 ++++++++ e2e/test_rtsp_zte_nat.py | 291 ++++++++++++++++++ ikuai-support/rtp2httpd/app/option.json | 13 + ikuai-support/rtp2httpd/scripts/start.sh | 3 + .../luci-static/resources/view/rtp2httpd.js | 15 + .../po/templates/rtp2httpd.pot | 20 ++ .../po/zh_Hans/rtp2httpd.po | 20 ++ .../rtp2httpd/files/rtp2httpd.conf | 1 + .../rtp2httpd/files/rtp2httpd.init | 1 + rtp2httpd.conf | 5 + src/configuration.c | 73 ++++- src/configuration.h | 28 +- src/rtsp.c | 164 +++++++++- src/rtsp.h | 11 + 17 files changed, 781 insertions(+), 18 deletions(-) create mode 100644 e2e/test_rtsp_zte_nat.py diff --git a/docs/en/reference/configuration.md b/docs/en/reference/configuration.md index b606c6e1..324dd199 100644 --- a/docs/en/reference/configuration.md +++ b/docs/en/reference/configuration.md @@ -108,6 +108,13 @@ Unix socket listen paths must be absolute and must not contain whitespace. At st - Format: `host:port` or `host` (default port: 3478) - Example: `stun.miwifi.com` or `stun.miwifi.com:3478` +- `--rtsp-nat-mode ` - RTSP NAT traversal mode + - `none`: Disable STUN and ZTE traversal + - `stun`: Use `rtsp-stun-server` to discover the public UDP port; a valid STUN server is required + - `zte`: Use the ZTE `ZXV10STB` UDP punch protocol without contacting the configured STUN server + - When omitted, legacy configurations implicitly use `stun` if `rtsp-stun-server` is set, or `none` otherwise + - ZTE mode supports IPv4 only. Its address comes from the actual local endpoint of the connected RTSP TCP socket, so it follows the priority of `r2h-ifname`, `upstream-interface-rtsp`, `upstream-interface`, and the system routing table + ### Other Options - `-S, --video-snapshot` - Enable video snapshot feature (default: disabled) @@ -245,6 +252,10 @@ http-proxy-user-agent = rtp2httpd-http-proxy/1.0 # Configure this when an upstream RTSP server requires a specific User-Agent for compatibility rtsp-user-agent = rtp2httpd/custom +# RTSP NAT traversal mode: none, stun, or zte (default: none) +# If omitted while rtsp-stun-server is configured, stun is selected for backward compatibility +rtsp-nat-mode = none + # STUN server for RTSP NAT traversal (default: disabled) # When an RTSP server only supports UDP transport and the client is behind NAT, STUN can be used to attempt NAT traversal (not guaranteed to succeed) # Format: host:port or host (default port: 3478) diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 509282ef..db9c8d9a 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -108,6 +108,13 @@ Unix socket 监听路径必须是绝对路径,且路径中不能包含空白 - 格式:`host:port` 或 `host`(默认端口 3478) - 示例:`stun.miwifi.com` 或 `stun.miwifi.com:3478` +- `--rtsp-nat-mode ` - RTSP NAT 穿透模式 + - `none`:禁用 STUN 和 ZTE 穿透 + - `stun`:使用 `rtsp-stun-server` 探测公网 UDP 端口;必须配置有效的 STUN 服务器 + - `zte`:使用中兴 `ZXV10STB` UDP 打洞协议,不访问已配置的 STUN 服务器 + - 未显式设置时,为兼容旧配置,存在 `rtsp-stun-server` 即使用 `stun`,否则使用 `none` + - ZTE 模式仅支持 IPv4。其地址取自 RTSP TCP 连接成功后的实际本地 endpoint,因此会遵循 `r2h-ifname`、`upstream-interface-rtsp`、`upstream-interface` 和系统路由表的优先级 + ### 其他 - `-S, --video-snapshot` - 启用视频快照功能 (默认: 关闭) @@ -243,6 +250,10 @@ http-proxy-user-agent = rtp2httpd-http-proxy/1.0 # 当上游 RTSP 服务器要求特定 User-Agent 时可配置此项 rtsp-user-agent = rtp2httpd/custom +# RTSP NAT 穿透模式:none、stun 或 zte(默认: none) +# 未设置此项但配置了 rtsp-stun-server 时,自动使用 stun 以兼容旧配置 +rtsp-nat-mode = none + # STUN 服务器用于 RTSP NAT 穿透(默认: 禁用) # 当 RTSP 服务器仅支持 UDP 传输且客户端位于 NAT 后时,可尝试使用 STUN 进行 NAT 穿透(不保证成功) # 格式: host:port 或 host(默认端口 3478) diff --git a/e2e/helpers/__init__.py b/e2e/helpers/__init__.py index ca4ecdb5..c78c9e61 100644 --- a/e2e/helpers/__init__.py +++ b/e2e/helpers/__init__.py @@ -46,6 +46,7 @@ MockRTSPServerNoTeardownResponse, MockRTSPServerSilent, MockRTSPServerUDP, + MockRTSPServerZTE, ) from .mock_stun import MockSTUNServer from .ports import ( @@ -73,6 +74,7 @@ "MockRTSPServerNoTeardownResponse", "MockRTSPServerSilent", "MockRTSPServerUDP", + "MockRTSPServerZTE", "MockSTUNServer", "MulticastSender", "R2HProcess", diff --git a/e2e/helpers/mock_rtsp.py b/e2e/helpers/mock_rtsp.py index e315e785..75504211 100644 --- a/e2e/helpers/mock_rtsp.py +++ b/e2e/helpers/mock_rtsp.py @@ -78,6 +78,8 @@ def __init__( self._stop = threading.Event() self.requests_received: list[str] = [] self.requests_detailed: list[dict] = [] + self.control_peer: tuple | None = None + self.events: list[str] = [] # -- lifecycle ----------------------------------------------------------- @@ -126,6 +128,7 @@ def _accept(self) -> None: def _handle(self, conn: socket.socket, addr: tuple) -> None: conn.settimeout(10.0) + self.control_peer = addr transport_hdr = "" try: while True: @@ -236,7 +239,9 @@ def _handle(self, conn: socket.socket, addr: tuple) -> None: return elif method == "SETUP": conn.sendall(self._setup_response(cseq, transport_hdr).encode()) + self.events.append("setup_response_sent") elif method == "PLAY": + self.events.append("play_received") extra_headers = "".join("%s: %s\r\n" % item for item in self._play_response_headers) conn.sendall( ( @@ -401,6 +406,131 @@ def _after_play(self, conn: socket.socket, addr: tuple) -> None: udp_sock.close() +# --------------------------------------------------------------------------- +# MockRTSPServerZTE -- ZTE UDP NAT traversal mode +# --------------------------------------------------------------------------- + + +class MockRTSPServerZTE(_RTSPServerBase): + """RTSP server that starts UDP media only after a valid ZTE punch packet.""" + + def __init__(self, port: int = 0, num_packets: int = 200): + super().__init__(port) + self._num_packets = num_packets + self._server_rtp_socket: socket.socket | None = None + self._server_rtcp_socket: socket.socket | None = None + self._receiver_thread: threading.Thread | None = None + self._play_started = threading.Event() + self._valid_probe = threading.Event() + self._client_rtp_port = 0 + self._client_address = "" + self._server_rtp_port = 0 + self._server_rtcp_port = 0 + self.udp_datagrams: list[tuple[bytes, tuple]] = [] + + @property + def valid_probe_received(self) -> bool: + return self._valid_probe.is_set() + + def stop(self) -> None: + self._play_started.set() + super().stop() + if self._server_rtp_socket: + self._server_rtp_socket.close() + if self._server_rtcp_socket: + self._server_rtcp_socket.close() + if self._receiver_thread: + self._receiver_thread.join(timeout=2) + + def _setup_response(self, cseq: str, transport_hdr: str) -> str: + for part in transport_hdr.split(";"): + part = part.strip() + if part.startswith("client_port="): + self._client_rtp_port = int(part.split("=", 1)[1].split("-", 1)[0]) + elif part.startswith("client_address="): + self._client_address = part.split("=", 1)[1] + + while True: + rtp_port, rtcp_port = find_free_udp_port_pair() + rtp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + rtcp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + rtp_socket.bind((self.host, rtp_port)) + rtcp_socket.bind((self.host, rtcp_port)) + break + except OSError: + rtp_socket.close() + rtcp_socket.close() + + self._server_rtp_socket = rtp_socket + self._server_rtcp_socket = rtcp_socket + self._server_rtp_port = rtp_port + self._server_rtcp_port = rtcp_port + self._receiver_thread = threading.Thread(target=self._receive_probes, daemon=True) + self._receiver_thread.start() + + return ( + "RTSP/1.0 200 OK\r\nCSeq: %s\r\n" + "Transport: MP2T/RTP/UDP;unicast;client_port=%d-%d;server_port=%d-%d\r\n" + "Session: t1\r\n\r\n" + % (cseq, self._client_rtp_port, self._client_rtp_port + 1, self._server_rtp_port, self._server_rtcp_port) + ) + + def _receive_probes(self) -> None: + assert self._server_rtp_socket is not None + self._server_rtp_socket.settimeout(0.05) + while not self._stop.is_set(): + try: + payload, source = self._server_rtp_socket.recvfrom(2048) + self.udp_datagrams.append((payload, source)) + if self._probe_is_valid(payload, source): + self.events.append("zte_probe_received") + self._valid_probe.set() + except socket.timeout: + if self._play_started.is_set(): + return + except OSError: + return + + def _probe_is_valid(self, payload: bytes, source: tuple) -> bool: + if not self.control_peer or len(payload) != 84: + return False + expected_ip = socket.inet_aton(self.control_peer[0]) + expected_tcp_port = self.control_peer[1] + return ( + payload[:8] == b"ZXV10STB" + and payload[8:12] == b"\x7f\xff\xff\xff" + and payload[12:16] == expected_ip + and struct.unpack("!H", payload[16:18])[0] == self._client_rtp_port + and struct.unpack("!H", payload[18:20])[0] == expected_tcp_port + and payload[20:] == bytes(64) + and source[0] == self.control_peer[0] + and source[1] == self._client_rtp_port + ) + + def _after_play(self, conn: socket.socket, addr: tuple) -> None: + self._play_started.set() + if not self._valid_probe.wait(timeout=2.0) or not self.udp_datagrams: + return + if self._receiver_thread: + self._receiver_thread.join(timeout=0.2) + + assert self._server_rtp_socket is not None + destination = self.udp_datagrams[0][1] + seq = 0 + ts = 0 + try: + for _ in range(self._num_packets): + if self._stop.is_set(): + break + self._server_rtp_socket.sendto(make_rtp_packet(seq, ts), destination) + seq = (seq + 1) & 0xFFFF + ts = (ts + 3600) & 0xFFFFFFFF + time.sleep(0.001) + except OSError: + pass + + # --------------------------------------------------------------------------- # MockRTSPServerSilent -- accepts connection but never responds # --------------------------------------------------------------------------- diff --git a/e2e/test_rtsp_zte_nat.py b/e2e/test_rtsp_zte_nat.py new file mode 100644 index 00000000..dd266875 --- /dev/null +++ b/e2e/test_rtsp_zte_nat.py @@ -0,0 +1,291 @@ +"""End-to-end coverage for the ZTE RTSP NAT traversal mode.""" + +import os +import signal +import socket +import struct +import time + +import pytest + +from helpers import ( + LOOPBACK_IF, + MockRTSPServer, + MockRTSPServerZTE, + MockSTUNServer, + R2HProcess, + build_config, + find_free_port, + ipv6_loopback_available, + stream_get, +) + +pytestmark = pytest.mark.rtsp + + +def _request(server, method): + return next(request for request in server.requests_detailed if request["method"] == method) + + +class TestZTEProtocol: + def test_zte_headers_endpoint_invariants_and_probe_bytes(self, r2h_binary): + stun = MockSTUNServer() + rtsp = MockRTSPServerZTE(num_packets=500) + stun.start() + rtsp.start() + r2h_port = find_free_port() + r2h = R2HProcess( + r2h_binary, + r2h_port, + extra_args=[ + "-v", + "4", + "-m", + "100", + "--rtsp-nat-mode", + "zte", + "--rtsp-stun-server", + "127.0.0.1:%d" % stun.port, + ], + capture_log=True, + ) + r2h.start() + try: + status, _, body = stream_get( + "127.0.0.1", + r2h_port, + "/rtsp/127.0.0.1:%d/stream" % rtsp.port, + read_bytes=4096, + timeout=20.0, + ) + assert status == 200 + assert len(body) >= 188 + assert rtsp.valid_probe_received + assert stun.requests_received == 0 + + assert rtsp.control_peer is not None + tcp_source_ip, tcp_source_port = rtsp.control_peer[:2] + expected_x_nat = "%s:%d" % (tcp_source_ip, tcp_source_port) + describe = _request(rtsp, "DESCRIBE") + setup = _request(rtsp, "SETUP") + assert describe["headers"]["x-NAT"] == expected_x_nat + assert setup["headers"]["x-NAT"] == expected_x_nat + + transport = setup["headers"]["Transport"] + assert transport == ( + "MP2T/RTP/UDP;unicast;client_address=%s;client_port=%d-%d;mode=PLAY" + % (tcp_source_ip, rtsp._client_rtp_port, rtsp._client_rtp_port + 1) + ) + + assert len(rtsp.udp_datagrams) == 1 + payload, udp_source = rtsp.udp_datagrams[0] + assert len(payload) == 84 + assert payload[:8] == b"ZXV10STB" + assert payload[8:12] == b"\x7f\xff\xff\xff" + assert payload[12:16] == socket.inet_aton(tcp_source_ip) + assert struct.unpack("!H", payload[16:18])[0] == rtsp._client_rtp_port + assert struct.unpack("!H", payload[18:20])[0] == tcp_source_port + assert payload[20:] == bytes(64) + assert udp_source[0] == tcp_source_ip + assert udp_source[1] == rtsp._client_rtp_port + assert rtsp.events.index("setup_response_sent") < rtsp.events.index("zte_probe_received") + assert rtsp.events.index("zte_probe_received") < rtsp.events.index("play_received") + + log = r2h.read_log() + assert "RTSP STUN server ignored because rtsp-nat-mode=zte" in log + assert "RTSP: Upstream interface route-selected, local endpoint %s:" % tcp_source_ip in log + finally: + r2h.stop() + rtsp.stop() + stun.stop() + + @pytest.mark.parametrize("interface_source", ["global", "request"]) + def test_interface_selection_keeps_tcp_and_udp_source_aligned(self, r2h_binary, interface_source): + rtsp = MockRTSPServerZTE(num_packets=200) + rtsp.start() + r2h_port = find_free_port() + extra_args = ["--rtsp-nat-mode", "zte"] + path = "/rtsp/127.0.0.1:%d/stream" % rtsp.port + if interface_source == "global": + extra_args.extend(["--upstream-interface-rtsp", LOOPBACK_IF]) + else: + path += "?r2h-ifname=%s" % LOOPBACK_IF + r2h = R2HProcess(r2h_binary, r2h_port, extra_args=extra_args) + r2h.start() + try: + status, _, body = stream_get("127.0.0.1", r2h_port, path, read_bytes=188, timeout=20.0) + assert status == 200 + assert body + assert rtsp.valid_probe_received + assert rtsp.control_peer is not None + assert rtsp.udp_datagrams[0][1][0] == rtsp.control_peer[0] + finally: + r2h.stop() + rtsp.stop() + + def test_ipv6_upstream_falls_back_to_ordinary_rtsp(self, r2h_binary): + if not ipv6_loopback_available(): + pytest.skip("IPv6 loopback is unavailable") + rtsp = MockRTSPServer(num_packets=300, host="::1") + rtsp.start() + r2h_port = find_free_port() + r2h = R2HProcess( + r2h_binary, + r2h_port, + extra_args=["-v", "4", "--rtsp-nat-mode", "zte"], + capture_log=True, + ) + r2h.start() + try: + status, _, body = stream_get( + "127.0.0.1", + r2h_port, + "/rtsp/[::1]:%d/stream" % rtsp.port, + read_bytes=188, + timeout=20.0, + ) + assert status == 200 + assert body + assert "x-NAT" not in _request(rtsp, "DESCRIBE")["headers"] + assert "ZTE NAT traversal only supports IPv4" in r2h.read_log() + finally: + r2h.stop() + rtsp.stop() + + def test_redirect_recaptures_control_endpoint(self, r2h_binary): + target = MockRTSPServerZTE(num_packets=300) + target.start() + redirect = MockRTSPServer(redirect_describe_to="rtsp://127.0.0.1:%d/stream" % target.port) + redirect.start() + r2h_port = find_free_port() + r2h = R2HProcess(r2h_binary, r2h_port, extra_args=["--rtsp-nat-mode", "zte"]) + r2h.start() + try: + status, _, body = stream_get( + "127.0.0.1", + r2h_port, + "/rtsp/127.0.0.1:%d/stream" % redirect.port, + read_bytes=188, + timeout=20.0, + ) + assert status == 200 + assert body + assert target.valid_probe_received + assert target.control_peer is not None + expected_x_nat = "%s:%d" % target.control_peer[:2] + assert _request(target, "DESCRIBE")["headers"]["x-NAT"] == expected_x_nat + finally: + r2h.stop() + redirect.stop() + target.stop() + + +class TestNATModeCompatibility: + def test_config_file_enables_zte(self, r2h_binary): + rtsp = MockRTSPServerZTE(num_packets=200) + rtsp.start() + r2h_port = find_free_port() + r2h = R2HProcess( + r2h_binary, + r2h_port, + config_content=build_config(r2h_port, global_lines=["rtsp-nat-mode = zte"]), + ) + r2h.start() + try: + status, _, body = stream_get( + "127.0.0.1", + r2h_port, + "/rtsp/127.0.0.1:%d/stream" % rtsp.port, + read_bytes=188, + timeout=20.0, + ) + assert status == 200 + assert body + assert rtsp.valid_probe_received + finally: + r2h.stop() + rtsp.stop() + + def test_explicit_none_disables_legacy_stun_inference(self, r2h_binary): + stun = MockSTUNServer() + rtsp = MockRTSPServer(num_packets=500) + stun.start() + rtsp.start() + r2h_port = find_free_port() + r2h = R2HProcess( + r2h_binary, + r2h_port, + extra_args=[ + "--rtsp-nat-mode", + "none", + "--rtsp-stun-server", + "127.0.0.1:%d" % stun.port, + ], + ) + r2h.start() + try: + status, _, body = stream_get( + "127.0.0.1", + r2h_port, + "/rtsp/127.0.0.1:%d/stream" % rtsp.port, + read_bytes=4096, + timeout=20.0, + ) + assert status == 200 + assert body + time.sleep(0.1) + assert stun.requests_received == 0 + assert "x-NAT" not in _request(rtsp, "DESCRIBE")["headers"] + finally: + r2h.stop() + rtsp.stop() + stun.stop() + + def test_explicit_stun_requires_server(self, r2h_binary): + r2h = R2HProcess( + r2h_binary, + find_free_port(), + extra_args=["--rtsp-nat-mode", "stun"], + capture_log=True, + ) + r2h.start(wait=False) + try: + assert r2h.process is not None + assert r2h.process.wait(timeout=5) != 0 + assert "rtsp-nat-mode=stun requires rtsp-stun-server" in r2h.read_log() + finally: + r2h.stop() + + def test_cli_mode_override_survives_reload(self, r2h_binary): + rtsp = MockRTSPServer(num_packets=300) + rtsp.start() + r2h_port = find_free_port() + r2h = R2HProcess( + r2h_binary, + r2h_port, + config_content=build_config(r2h_port, global_lines=["rtsp-nat-mode = zte"]), + extra_args=["--rtsp-nat-mode", "none"], + capture_log=True, + ) + r2h.start() + try: + path = "/rtsp/127.0.0.1:%d/stream" % rtsp.port + status, _, body = stream_get("127.0.0.1", r2h_port, path, read_bytes=188, timeout=20.0) + assert status == 200 + assert body + + assert r2h.process is not None + os.kill(r2h.process.pid, signal.SIGHUP) + deadline = time.time() + 5.0 + while "Configuration reloaded successfully" not in r2h.read_log() and time.time() < deadline: + time.sleep(0.05) + + status, _, body = stream_get("127.0.0.1", r2h_port, path, read_bytes=188, timeout=20.0) + assert status == 200 + assert body + describe_requests = [request for request in rtsp.requests_detailed if request["method"] == "DESCRIBE"] + assert len(describe_requests) >= 2 + assert all("x-NAT" not in request["headers"] for request in describe_requests) + finally: + r2h.stop() + rtsp.stop() diff --git a/ikuai-support/rtp2httpd/app/option.json b/ikuai-support/rtp2httpd/app/option.json index 6077d3ba..5e9f1fc1 100644 --- a/ikuai-support/rtp2httpd/app/option.json +++ b/ikuai-support/rtp2httpd/app/option.json @@ -298,6 +298,19 @@ "min": 0, "max": 1 }, + { + "default": "none", + "attrname": "RTP2HTTPD_RTSP_NAT_MODE", + "label": { + "en": "RTSP NAT mode: none, stun, or zte", + "zh": "RTSP NAT 模式:none、stun 或 zte" + }, + "required": true, + "scope": "config", + "type": "text", + "min": 3, + "max": 4 + }, { "default": "", "attrname": "RTP2HTTPD_RTSP_STUN_SERVER", diff --git a/ikuai-support/rtp2httpd/scripts/start.sh b/ikuai-support/rtp2httpd/scripts/start.sh index 3c0099ec..fb3de600 100755 --- a/ikuai-support/rtp2httpd/scripts/start.sh +++ b/ikuai-support/rtp2httpd/scripts/start.sh @@ -161,6 +161,9 @@ fi if [ "$RTP2HTTPD_ZEROCOPY_ON_SEND" = "1" ]; then set -- "$@" --zerocopy-on-send fi +if [ -n "$RTP2HTTPD_RTSP_NAT_MODE" ]; then + set -- "$@" --rtsp-nat-mode "$RTP2HTTPD_RTSP_NAT_MODE" +fi if [ -n "$RTP2HTTPD_RTSP_STUN_SERVER" ]; then set -- "$@" --rtsp-stun-server "$RTP2HTTPD_RTSP_STUN_SERVER" fi diff --git a/openwrt-support/luci-app-rtp2httpd/htdocs/luci-static/resources/view/rtp2httpd.js b/openwrt-support/luci-app-rtp2httpd/htdocs/luci-static/resources/view/rtp2httpd.js index cb36d09b..d7b8e486 100644 --- a/openwrt-support/luci-app-rtp2httpd/htdocs/luci-static/resources/view/rtp2httpd.js +++ b/openwrt-support/luci-app-rtp2httpd/htdocs/luci-static/resources/view/rtp2httpd.js @@ -634,6 +634,21 @@ return view.extend({ o.default = "0"; o.depends("use_config_file", "0"); + o = s.taboption( + "network", + form.ListValue, + "rtsp_nat_mode", + _("RTSP NAT Traversal Mode"), + _( + "Select none to disable traversal, STUN for standard address discovery, or ZTE for the ZXV10STB UDP punch protocol. ZTE uses the actual IPv4 endpoint selected for the RTSP TCP connection and ignores the STUN server." + ) + ); + o.value("none", _("None")); + o.value("stun", _("STUN")); + o.value("zte", _("ZTE")); + o.default = "none"; + o.depends("use_config_file", "0"); + o = s.taboption( "network", form.Value, diff --git a/openwrt-support/luci-app-rtp2httpd/po/templates/rtp2httpd.pot b/openwrt-support/luci-app-rtp2httpd/po/templates/rtp2httpd.pot index cb2972bd..818db230 100644 --- a/openwrt-support/luci-app-rtp2httpd/po/templates/rtp2httpd.pot +++ b/openwrt-support/luci-app-rtp2httpd/po/templates/rtp2httpd.pot @@ -410,6 +410,26 @@ msgstr "" msgid "rtp2httpd" msgstr "" +#: htdocs/luci-static/resources/view/rtp2httpd.js:644 +msgid "RTSP NAT Traversal Mode" +msgstr "" + +#: htdocs/luci-static/resources/view/rtp2httpd.js:646 +msgid "Select none to disable traversal, STUN for standard address discovery, or ZTE for the ZXV10STB UDP punch protocol. ZTE uses the actual IPv4 endpoint selected for the RTSP TCP connection and ignores the STUN server." +msgstr "" + +#: htdocs/luci-static/resources/view/rtp2httpd.js:650 +msgid "None" +msgstr "" + +#: htdocs/luci-static/resources/view/rtp2httpd.js:651 +msgid "STUN" +msgstr "" + +#: htdocs/luci-static/resources/view/rtp2httpd.js:652 +msgid "ZTE" +msgstr "" + #: htdocs/luci-static/resources/view/rtp2httpd.js:281 msgid "" "rtp2httpd converts RTP/UDP/RTSP media into http stream. Here you can " diff --git a/openwrt-support/luci-app-rtp2httpd/po/zh_Hans/rtp2httpd.po b/openwrt-support/luci-app-rtp2httpd/po/zh_Hans/rtp2httpd.po index 280b0045..3cd46096 100644 --- a/openwrt-support/luci-app-rtp2httpd/po/zh_Hans/rtp2httpd.po +++ b/openwrt-support/luci-app-rtp2httpd/po/zh_Hans/rtp2httpd.po @@ -457,6 +457,26 @@ msgstr "启用零拷贝发送" msgid "rtp2httpd" msgstr "rtp2httpd" +#: htdocs/luci-static/resources/view/rtp2httpd.js:644 +msgid "RTSP NAT Traversal Mode" +msgstr "RTSP NAT 穿透模式" + +#: htdocs/luci-static/resources/view/rtp2httpd.js:646 +msgid "Select none to disable traversal, STUN for standard address discovery, or ZTE for the ZXV10STB UDP punch protocol. ZTE uses the actual IPv4 endpoint selected for the RTSP TCP connection and ignores the STUN server." +msgstr "选择 none 可禁用穿透,选择 STUN 可进行标准地址探测,选择 ZTE 可使用 ZXV10STB UDP 打洞协议。ZTE 使用 RTSP TCP 连接实际选择的 IPv4 endpoint,并忽略 STUN 服务器。" + +#: htdocs/luci-static/resources/view/rtp2httpd.js:650 +msgid "None" +msgstr "无" + +#: htdocs/luci-static/resources/view/rtp2httpd.js:651 +msgid "STUN" +msgstr "STUN" + +#: htdocs/luci-static/resources/view/rtp2httpd.js:652 +msgid "ZTE" +msgstr "ZTE" + #: htdocs/luci-static/resources/view/rtp2httpd.js:281 msgid "" "rtp2httpd converts RTP/UDP/RTSP media into http stream. Here you can " diff --git a/openwrt-support/rtp2httpd/files/rtp2httpd.conf b/openwrt-support/rtp2httpd/files/rtp2httpd.conf index 46285fb1..334c1cd0 100644 --- a/openwrt-support/rtp2httpd/files/rtp2httpd.conf +++ b/openwrt-support/rtp2httpd/files/rtp2httpd.conf @@ -47,6 +47,7 @@ config rtp2httpd # option zerocopy_on_send '0' # option http_proxy_user_agent 'rtp2httpd-http-proxy/1.0' # option rtsp_user_agent 'rtp2httpd/custom' + # option rtsp_nat_mode 'none' # option rtsp_stun_server 'stun.miwifi.com' # option cors_allow_origin '*' # option ffmpeg_path 'ffmpeg' diff --git a/openwrt-support/rtp2httpd/files/rtp2httpd.init b/openwrt-support/rtp2httpd/files/rtp2httpd.init index b8674cfb..d094d09b 100644 --- a/openwrt-support/rtp2httpd/files/rtp2httpd.init +++ b/openwrt-support/rtp2httpd/files/rtp2httpd.init @@ -87,6 +87,7 @@ start_instance() { append_arg "$cfg" fcc_listen_port_range "--fcc-listen-port-range" append_arg "$cfg" http_proxy_user_agent "--http-proxy-user-agent" append_arg "$cfg" rtsp_user_agent "--rtsp-user-agent" + append_arg "$cfg" rtsp_nat_mode "--rtsp-nat-mode" append_arg "$cfg" rtsp_stun_server "--rtsp-stun-server" append_arg "$cfg" cors_allow_origin "--cors-allow-origin" append_arg "$cfg" ffmpeg_path "--ffmpeg-path" diff --git a/rtp2httpd.conf b/rtp2httpd.conf index cffa879f..dae12508 100644 --- a/rtp2httpd.conf +++ b/rtp2httpd.conf @@ -119,6 +119,11 @@ verbosity = 1 # User-Agent header used for upstream RTSP requests (default: rtp2httpd/) ;rtsp-user-agent = rtp2httpd/custom +# RTSP NAT traversal mode: none, stun, or zte (default: none) +# For backward compatibility, omitting this option enables STUN automatically +# when rtsp-stun-server is configured. +;rtsp-nat-mode = none + # STUN server for RTSP NAT traversal (default: disabled) # When RTSP server only supports UDP transport and client is behind NAT, # try using STUN for NAT traversal (may not always succeed) diff --git a/src/configuration.c b/src/configuration.c index db1f4c0b..13f06f71 100644 --- a/src/configuration.c +++ b/src/configuration.c @@ -50,6 +50,7 @@ int cmd_zerocopy_on_send_set = 0; int cmd_workers_set = 0; int cmd_external_m3u_url_set = 0; int cmd_external_m3u_update_interval_set = 0; +int cmd_rtsp_nat_mode_set = 0; int cmd_rtsp_stun_server_set = 0; int cmd_http_proxy_user_agent_set = 0; int cmd_rtsp_user_agent_set = 0; @@ -65,7 +66,8 @@ enum long_option_e { OPT_USE_RELATIVE_PATH_IN_M3U, OPT_ACCESS_LOG, OPT_LOG_FORMAT, - OPT_PID_FILE + OPT_PID_FILE, + OPT_RTSP_NAT_MODE }; /* M3U parsing state variables */ @@ -96,6 +98,45 @@ static int parse_bool(const char *value) { (strcasecmp("1", value) == 0); } +static rtsp_nat_mode_t parse_rtsp_nat_mode(const char *value) { + if (strcasecmp(value, "none") == 0) + return RTSP_NAT_MODE_NONE; + if (strcasecmp(value, "stun") == 0) + return RTSP_NAT_MODE_STUN; + if (strcasecmp(value, "zte") == 0) + return RTSP_NAT_MODE_ZTE; + return RTSP_NAT_MODE_INVALID; +} + +rtsp_nat_mode_t config_get_effective_rtsp_nat_mode(void) { + if (config.rtsp_nat_mode == RTSP_NAT_MODE_AUTO) { + if (config.rtsp_stun_server && config.rtsp_stun_server[0] != '\0') + return RTSP_NAT_MODE_STUN; + return RTSP_NAT_MODE_NONE; + } + return config.rtsp_nat_mode; +} + +static int validate_rtsp_nat_config(void) { + static int zte_stun_warning_logged = 0; + rtsp_nat_mode_t mode = config_get_effective_rtsp_nat_mode(); + + if (mode == RTSP_NAT_MODE_INVALID) { + logger(LOG_ERROR, "Invalid rtsp-nat-mode (expected none, stun, or zte)"); + return -1; + } + if (mode == RTSP_NAT_MODE_STUN && (!config.rtsp_stun_server || config.rtsp_stun_server[0] == '\0')) { + logger(LOG_ERROR, "rtsp-nat-mode=stun requires rtsp-stun-server"); + return -1; + } + if (mode == RTSP_NAT_MODE_ZTE && config.rtsp_stun_server && config.rtsp_stun_server[0] != '\0' && + !zte_stun_warning_logged) { + logger(LOG_WARN, "RTSP STUN server ignored because rtsp-nat-mode=zte"); + zte_stun_warning_logged = 1; + } + return 0; +} + /* Set config value if not already set by command line */ static int set_if_not_cmd_override(int cmd_flag, const char *param_name) { if (cmd_flag) { @@ -751,7 +792,16 @@ void parse_global_sec(char *line) { return; } - /* STUN NAT traversal configuration */ + /* RTSP NAT traversal configuration */ + if (strcasecmp("rtsp-nat-mode", param) == 0) { + if (set_if_not_cmd_override(cmd_rtsp_nat_mode_set, "rtsp-nat-mode")) { + config.rtsp_nat_mode = parse_rtsp_nat_mode(value); + if (config.rtsp_nat_mode == RTSP_NAT_MODE_INVALID) + logger(LOG_ERROR, "Invalid rtsp-nat-mode value: %s", value); + } + return; + } + if (strcasecmp("rtsp-stun-server", param) == 0) { if (!cmd_rtsp_stun_server_set) { safe_free_string(&config.rtsp_stun_server); @@ -1176,6 +1226,8 @@ void config_init(void) { config.zerocopy_on_send = 0; if (!cmd_use_relative_path_in_m3u_set) config.use_relative_path_in_m3u = 0; + if (!cmd_rtsp_nat_mode_set) + config.rtsp_nat_mode = RTSP_NAT_MODE_AUTO; if (!cmd_fcc_listen_port_range_set) { config.fcc_listen_port_min = 0; config.fcc_listen_port_max = 0; @@ -1257,6 +1309,9 @@ int config_reload(int *out_bind_changed) { return -1; } + if (validate_rtsp_nat_config() < 0) + return -1; + apply_bind_side_effects(); /* Check if bind addresses changed */ @@ -1349,6 +1404,8 @@ void usage(FILE *f, char *progname) { "(default: rtp2httpd/)\n" "\t-N --rtsp-stun-server STUN server for RTSP NAT traversal " "(default: disabled)\n" + "\t --rtsp-nat-mode RTSP NAT traversal mode " + "(default: infer stun when rtsp-stun-server is set)\n" "\t-O --cors-allow-origin Set Access-Control-Allow-Origin header " "(default: disabled)\n" "\t --access-log Write access logs to this file (default: disabled)\n" @@ -1432,6 +1489,7 @@ void parse_cmd_line(int argc, char *argv[]) { {"zerocopy-on-send", no_argument, 0, 'Z'}, {"http-proxy-user-agent", required_argument, 0, 'g'}, {"rtsp-stun-server", required_argument, 0, 'N'}, + {"rtsp-nat-mode", required_argument, 0, OPT_RTSP_NAT_MODE}, {"rtsp-user-agent", required_argument, 0, 'u'}, {"cors-allow-origin", required_argument, 0, 'O'}, {"access-log", required_argument, 0, OPT_ACCESS_LOG}, @@ -1645,6 +1703,14 @@ void parse_cmd_line(int argc, char *argv[]) { cmd_rtsp_stun_server_set = 1; logger(LOG_INFO, "RTSP STUN server: %s", config.rtsp_stun_server); break; + case OPT_RTSP_NAT_MODE: + config.rtsp_nat_mode = parse_rtsp_nat_mode(optarg); + if (config.rtsp_nat_mode == RTSP_NAT_MODE_INVALID) { + logger(LOG_FATAL, "Invalid --rtsp-nat-mode value: %s (expected none, stun, or zte)", optarg); + exit(EXIT_FAILURE); + } + cmd_rtsp_nat_mode_set = 1; + break; case 'u': safe_free_string(&config.rtsp_user_agent); if (optarg[0] != '\0') { @@ -1695,6 +1761,9 @@ void parse_cmd_line(int argc, char *argv[]) { set_config_file_path(NULL); } + if (validate_rtsp_nat_config() < 0) + exit(EXIT_FAILURE); + apply_bind_side_effects(); /* External M3U will be loaded asynchronously by workers after startup diff --git a/src/configuration.h b/src/configuration.h index 11f5e631..8ee283e7 100644 --- a/src/configuration.h +++ b/src/configuration.h @@ -26,6 +26,14 @@ typedef enum loglevel { typedef enum { BIND_ADDR_TCP = 0, BIND_ADDR_UNIX } bindaddr_type_t; +typedef enum { + RTSP_NAT_MODE_INVALID = -1, + RTSP_NAT_MODE_AUTO = 0, + RTSP_NAT_MODE_NONE, + RTSP_NAT_MODE_STUN, + RTSP_NAT_MODE_ZTE +} rtsp_nat_mode_t; + /* * Linked list of addresses to bind */ @@ -118,13 +126,14 @@ typedef struct { int zerocopy_on_send; /* Enable zero-copy send with MSG_ZEROCOPY (0=disabled, 1=enabled) */ - /* STUN NAT traversal settings */ - char *rtsp_stun_server; /* STUN server host:port for RTSP NAT traversal - (NULL=disabled) */ - char *http_proxy_user_agent; /* Override User-Agent header for upstream HTTP - proxy requests (NULL=disabled) */ - char *rtsp_user_agent; /* User-Agent header for upstream RTSP requests - (NULL=use default) */ + /* RTSP NAT traversal settings */ + rtsp_nat_mode_t rtsp_nat_mode; /* Explicit NAT traversal mode, or AUTO for legacy inference */ + char *rtsp_stun_server; /* STUN server host:port for RTSP NAT traversal + (NULL=disabled) */ + char *http_proxy_user_agent; /* Override User-Agent header for upstream HTTP + proxy requests (NULL=disabled) */ + char *rtsp_user_agent; /* User-Agent header for upstream RTSP requests + (NULL=use default) */ /* CORS settings */ char *cors_allow_origin; /* CORS Access-Control-Allow-Origin value @@ -233,4 +242,9 @@ int bind_addresses_equal(bindaddr_t *a, bindaddr_t *b); */ int bind_addresses_has_unix(void); +/** + * Resolve the configured RTSP NAT mode, including legacy STUN inference. + */ +rtsp_nat_mode_t config_get_effective_rtsp_nat_mode(void); + #endif /* __CONFIGURATION_H__ */ diff --git a/src/rtsp.c b/src/rtsp.c index ace92e3a..e42194bb 100644 --- a/src/rtsp.c +++ b/src/rtsp.c @@ -53,6 +53,8 @@ static void rtsp_close_udp_sockets(rtsp_session_t *session, const char *reason); static char *rtsp_find_header(const char *response, const char *header_name); static void rtsp_parse_transport_header(rtsp_session_t *session, const char *transport); static void rtsp_send_udp_nat_probe(rtsp_session_t *session); +static void rtsp_send_zte_nat_probe(rtsp_session_t *session); +static int rtsp_capture_control_endpoints(rtsp_session_t *session); static int rtsp_process_interleaved_buffer(rtsp_session_t *session, connection_t *conn); static int rtsp_handle_redirect(rtsp_session_t *session, const char *location); static void rtsp_parse_describe_sdp(rtsp_session_t *session, const char *header_start, const char *sdp_body); @@ -300,6 +302,12 @@ void rtsp_session_init(rtsp_session_t *session) { session->metadata_probe = 0; session->peer_closed = 0; session->connect_generation = 0; + session->control_local_addr_len = 0; + session->control_peer_addr_len = 0; + session->control_local_ip[0] = '\0'; + session->control_local_port = 0; + session->control_endpoints_valid = 0; + session->zte_active = 0; /* Initialize transport parameters - mode will be negotiated during SETUP */ session->transport_mode = RTSP_TRANSPORT_TCP; /* Default preference */ @@ -739,6 +747,73 @@ static void rtsp_free_connect_results(rtsp_session_t *session) { } } +static int rtsp_capture_control_endpoints(rtsp_session_t *session) { + struct sockaddr_storage local_addr; + struct sockaddr_storage peer_addr; + socklen_t local_len = sizeof(local_addr); + socklen_t peer_len = sizeof(peer_addr); + char local_host[RTSP_SERVER_HOST_SIZE]; + char local_service[RTSP_PORT_STRING_SIZE]; + int local_ip_changed = 0; + int zte_requested = config_get_effective_rtsp_nat_mode() == RTSP_NAT_MODE_ZTE; + + memset(&local_addr, 0, sizeof(local_addr)); + memset(&peer_addr, 0, sizeof(peer_addr)); + if (getsockname(session->socket, (struct sockaddr *)&local_addr, &local_len) < 0) { + logger(LOG_ERROR, "RTSP: getsockname() failed: %s", strerror(errno)); + session->control_endpoints_valid = 0; + session->zte_active = 0; + return -1; + } + if (getpeername(session->socket, (struct sockaddr *)&peer_addr, &peer_len) < 0) { + logger(LOG_ERROR, "RTSP: getpeername() failed: %s", strerror(errno)); + session->control_endpoints_valid = 0; + session->zte_active = 0; + return -1; + } + if (getnameinfo((struct sockaddr *)&local_addr, local_len, local_host, sizeof(local_host), local_service, + sizeof(local_service), NI_NUMERICHOST | NI_NUMERICSERV) != 0) { + logger(LOG_ERROR, "RTSP: Failed to format local control endpoint"); + session->control_endpoints_valid = 0; + session->zte_active = 0; + return -1; + } + + if (session->control_endpoints_valid && session->control_local_addr.ss_family == AF_INET && + local_addr.ss_family == AF_INET) { + const struct sockaddr_in *old_local = (const struct sockaddr_in *)&session->control_local_addr; + const struct sockaddr_in *new_local = (const struct sockaddr_in *)&local_addr; + local_ip_changed = old_local->sin_addr.s_addr != new_local->sin_addr.s_addr; + } + if (zte_requested && local_ip_changed && session->rtp_socket >= 0) + rtsp_close_udp_sockets(session, "control connection local address changed"); + + memcpy(&session->control_local_addr, &local_addr, local_len); + session->control_local_addr_len = local_len; + memcpy(&session->control_peer_addr, &peer_addr, peer_len); + session->control_peer_addr_len = peer_len; + snprintf(session->control_local_ip, sizeof(session->control_local_ip), "%s", local_host); + session->control_local_port = (uint16_t)atoi(local_service); + session->control_endpoints_valid = 1; + session->zte_active = zte_requested && local_addr.ss_family == AF_INET && peer_addr.ss_family == AF_INET; + + logger(LOG_INFO, "RTSP: Upstream interface %s, local endpoint %s:%u", + session->upstream_ifname && session->upstream_ifname[0] ? session->upstream_ifname : "route-selected", + session->control_local_ip, session->control_local_port); + +#ifdef __FreeBSD__ + if (zte_requested && session->upstream_ifname && session->upstream_ifname[0]) + logger(LOG_WARN, + "RTSP: FreeBSD cannot reliably pin unicast sockets to upstream interface %s; using the actual " + "route-selected local endpoint", + session->upstream_ifname); +#endif + if (zte_requested && !session->zte_active) + logger(LOG_WARN, "RTSP: ZTE NAT traversal only supports IPv4 control connections; using ordinary RTSP negotiation"); + + return 0; +} + /** * Try connecting to the next candidate from the getaddrinfo result list * (sequential dual-stack fallback, IPv6/IPv4 in resolver order). @@ -874,7 +949,7 @@ int rtsp_connect(rtsp_session_t *session) { * This allows STUN to run in parallel with TCP connection establishment * Only do this on initial connect (not on redirect or reconnect for TEARDOWN) * Check: UDP socket not yet created and STUN not already in progress/completed */ - if (!session->metadata_probe && config.rtsp_stun_server && config.rtsp_stun_server[0] != '\0' && + if (!session->metadata_probe && config_get_effective_rtsp_nat_mode() == RTSP_NAT_MODE_STUN && session->rtp_socket < 0 && !session->stun.in_progress && !session->stun.completed) { if (rtsp_setup_udp_sockets(session) == 0) { if (stun_send_request(&session->stun, session->rtp_socket) == 0) { @@ -963,6 +1038,7 @@ int rtsp_handle_socket_event(rtsp_session_t *session, uint32_t events) { /* Connection succeeded - drop remaining candidates */ rtsp_free_connect_results(session); logger(LOG_INFO, "RTSP: Connected to %s:%d", session->server_host, session->server_port); + rtsp_capture_control_endpoints(session); /* Update poller to monitor both read and write */ if (session->epoll_fd >= 0) { @@ -1268,7 +1344,7 @@ int rtsp_send_keepalive(rtsp_session_t *session) { } /* Send NAT probe packets to maintain UDP hole-punching (UDP only) */ - if (session->transport_mode == RTSP_TRANSPORT_UDP) { + if (session->transport_mode == RTSP_TRANSPORT_UDP && !session->zte_active) { rtsp_send_udp_nat_probe(session); } @@ -1599,7 +1675,12 @@ int rtsp_state_machine_advance(rtsp_session_t *session) { case RTSP_STATE_AWAITING_OPTIONS: /* OPTIONS response received, ready to send DESCRIBE */ - snprintf(extra_headers, sizeof(extra_headers), "Accept: application/sdp\r\n"); + if (session->zte_active) { + snprintf(extra_headers, sizeof(extra_headers), "Accept: application/sdp\r\nx-NAT: %s:%u\r\n", + session->control_local_ip, session->control_local_port); + } else { + snprintf(extra_headers, sizeof(extra_headers), "Accept: application/sdp\r\n"); + } if (rtsp_prepare_request(session, RTSP_METHOD_DESCRIBE, NULL, extra_headers) < 0) { logger(LOG_ERROR, "RTSP: Failed to prepare DESCRIBE request"); return -1; @@ -1628,6 +1709,14 @@ int rtsp_state_machine_advance(rtsp_session_t *session) { "RTP/AVP/TCP;unicast;interleaved=%d-%d\r\n", session->rtp_channel, session->rtcp_channel, session->rtp_channel, session->rtcp_channel, session->rtp_channel, session->rtcp_channel); + } else if (session->zte_active) { + advertised_rtp_port = session->local_rtp_port; + advertised_rtcp_port = session->local_rtcp_port; + snprintf(extra_headers, sizeof(extra_headers), + "Transport: MP2T/RTP/UDP;unicast;client_address=%s;client_port=%d-%d;mode=PLAY\r\n" + "x-NAT: %s:%u\r\n", + session->control_local_ip, advertised_rtp_port, advertised_rtcp_port, session->control_local_ip, + session->control_local_port); } else { /* Check STUN status and determine which port to advertise */ if (session->stun.in_progress) { @@ -1650,7 +1739,7 @@ int rtsp_state_machine_advance(rtsp_session_t *session) { } else { advertised_rtp_port = session->local_rtp_port; advertised_rtcp_port = session->local_rtcp_port; - if (config.rtsp_stun_server && config.rtsp_stun_server[0] != '\0') { + if (config_get_effective_rtsp_nat_mode() == RTSP_NAT_MODE_STUN) { logger(LOG_DEBUG, "RTSP: STUN timed out, using local ports %d-%d", advertised_rtp_port, advertised_rtcp_port); } } @@ -2542,7 +2631,12 @@ static int rtsp_setup_udp_sockets(rtsp_session_t *session) { } else { struct sockaddr_in *sin = (struct sockaddr_in *)&local_addr; sin->sin_family = AF_INET; - sin->sin_addr.s_addr = INADDR_ANY; + if (session->zte_active) { + const struct sockaddr_in *control_local = (const struct sockaddr_in *)&session->control_local_addr; + sin->sin_addr = control_local->sin_addr; + } else { + sin->sin_addr.s_addr = INADDR_ANY; + } local_addr_len = sizeof(struct sockaddr_in); } @@ -2661,8 +2755,13 @@ static int rtsp_setup_udp_sockets(rtsp_session_t *session) { logger(LOG_DEBUG, "RTSP: RTCP socket registered with poller"); } - logger(LOG_DEBUG, "RTSP: UDP sockets bound to ports %d (RTP) and %d (RTCP)", session->local_rtp_port, - session->local_rtcp_port); + if (session->zte_active) { + logger(LOG_DEBUG, "RTSP: UDP sockets bound to %s:%d (RTP) and %s:%d (RTCP)", session->control_local_ip, + session->local_rtp_port, session->control_local_ip, session->local_rtcp_port); + } else { + logger(LOG_DEBUG, "RTSP: UDP sockets bound to ports %d (RTP) and %d (RTCP)", session->local_rtp_port, + session->local_rtcp_port); + } return 0; } @@ -3100,6 +3199,49 @@ static void rtsp_send_udp_nat_probe(rtsp_session_t *session) { session->server_rtcp_port); } +static void rtsp_send_zte_nat_probe(rtsp_session_t *session) { + uint8_t packet[84]; + struct sockaddr_storage destination; + const struct sockaddr_in *local_addr; + uint16_t network_port; + ssize_t sent; + + if (!session || !session->zte_active || !session->control_endpoints_valid || session->rtp_socket < 0 || + session->control_peer_addr.ss_family != AF_INET) + return; + if (session->server_rtp_port <= 0 || session->server_rtp_port > 65535) { + logger(LOG_WARN, "RTSP: Cannot send ZTE NAT probe without a valid server RTP port"); + return; + } + + memset(packet, 0, sizeof(packet)); + memcpy(packet, "ZXV10STB", 8); + packet[8] = 0x7f; + packet[9] = 0xff; + packet[10] = 0xff; + packet[11] = 0xff; + + local_addr = (const struct sockaddr_in *)&session->control_local_addr; + memcpy(packet + 12, &local_addr->sin_addr.s_addr, sizeof(local_addr->sin_addr.s_addr)); + network_port = htons((uint16_t)session->local_rtp_port); + memcpy(packet + 16, &network_port, sizeof(network_port)); + network_port = htons(session->control_local_port); + memcpy(packet + 18, &network_port, sizeof(network_port)); + + memcpy(&destination, &session->control_peer_addr, session->control_peer_addr_len); + sockaddr_set_port((struct sockaddr *)&destination, (uint16_t)session->server_rtp_port); + sent = sendto(session->rtp_socket, packet, sizeof(packet), 0, (struct sockaddr *)&destination, + session->control_peer_addr_len); + if (sent != (ssize_t)sizeof(packet)) { + logger(LOG_WARN, "RTSP: Failed to send ZTE NAT probe to control peer port %d: %s", session->server_rtp_port, + sent < 0 ? strerror(errno) : "short send"); + return; + } + + logger(LOG_DEBUG, "RTSP: Sent 84-byte ZTE NAT probe from %s:%d to control peer port %d", session->control_local_ip, + session->local_rtp_port, session->server_rtp_port); +} + static void rtsp_parse_transport_header(rtsp_session_t *session, const char *transport) { char *server_port_param; char *interleaved_param; @@ -3204,8 +3346,12 @@ static void rtsp_parse_transport_header(rtsp_session_t *session, const char *tra } } - /* Send NAT probe packets if server provided source address and ports */ - rtsp_send_udp_nat_probe(session); + if (session->zte_active) { + rtsp_send_zte_nat_probe(session); + } else { + /* Send ordinary NAT probe packets if server provided source address and ports */ + rtsp_send_udp_nat_probe(session); + } } } diff --git a/src/rtsp.h b/src/rtsp.h index 73789911..3af5ccaf 100644 --- a/src/rtsp.h +++ b/src/rtsp.h @@ -2,6 +2,7 @@ #define __RTSP_H__ #include +#include #include #include "stun.h" @@ -152,6 +153,16 @@ typedef struct { * fd numbers cannot be used for this: close()+socket() often reuses them. */ unsigned connect_generation; + /* Actual control connection endpoints selected by the kernel. */ + struct sockaddr_storage control_local_addr; + socklen_t control_local_addr_len; + struct sockaddr_storage control_peer_addr; + socklen_t control_peer_addr_len; + char control_local_ip[RTSP_SERVER_HOST_SIZE]; + uint16_t control_local_port; + int control_endpoints_valid; + int zte_active; + /* Authentication state */ char username[RTSP_CREDENTIAL_SIZE]; /* RTSP username for authentication */ char password[RTSP_CREDENTIAL_SIZE]; /* RTSP password for authentication */ From 17c95a4e6c3e6a42fe0f1deaa3cc4e31b02cfa63 Mon Sep 17 00:00:00 2001 From: Stackie Jia Date: Mon, 3 Aug 2026 19:03:03 +0800 Subject: [PATCH 2/4] test(rtsp): avoid cross-protocol ordering race --- e2e/helpers/mock_rtsp.py | 4 ---- e2e/test_rtsp_zte_nat.py | 9 +++++++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/e2e/helpers/mock_rtsp.py b/e2e/helpers/mock_rtsp.py index 75504211..63d9dce2 100644 --- a/e2e/helpers/mock_rtsp.py +++ b/e2e/helpers/mock_rtsp.py @@ -79,7 +79,6 @@ def __init__( self.requests_received: list[str] = [] self.requests_detailed: list[dict] = [] self.control_peer: tuple | None = None - self.events: list[str] = [] # -- lifecycle ----------------------------------------------------------- @@ -239,9 +238,7 @@ def _handle(self, conn: socket.socket, addr: tuple) -> None: return elif method == "SETUP": conn.sendall(self._setup_response(cseq, transport_hdr).encode()) - self.events.append("setup_response_sent") elif method == "PLAY": - self.events.append("play_received") extra_headers = "".join("%s: %s\r\n" % item for item in self._play_response_headers) conn.sendall( ( @@ -484,7 +481,6 @@ def _receive_probes(self) -> None: payload, source = self._server_rtp_socket.recvfrom(2048) self.udp_datagrams.append((payload, source)) if self._probe_is_valid(payload, source): - self.events.append("zte_probe_received") self._valid_probe.set() except socket.timeout: if self._play_started.is_set(): diff --git a/e2e/test_rtsp_zte_nat.py b/e2e/test_rtsp_zte_nat.py index dd266875..ba49bf28 100644 --- a/e2e/test_rtsp_zte_nat.py +++ b/e2e/test_rtsp_zte_nat.py @@ -88,8 +88,13 @@ def test_zte_headers_endpoint_invariants_and_probe_bytes(self, r2h_binary): assert payload[20:] == bytes(64) assert udp_source[0] == tcp_source_ip assert udp_source[1] == rtsp._client_rtp_port - assert rtsp.events.index("setup_response_sent") < rtsp.events.index("zte_probe_received") - assert rtsp.events.index("zte_probe_received") < rtsp.events.index("play_received") + + # TCP control requests and UDP probes are consumed by separate mock + # threads, so their server-side observation order is inherently + # racy. The probe target port is disclosed only by the SETUP + # response, and MockRTSPServerZTE withholds media until this exact + # packet validates, which covers the protocol dependency without a + # cross-protocol scheduling assertion. log = r2h.read_log() assert "RTSP STUN server ignored because rtsp-nat-mode=zte" in log From fa476195c8fa7394962a0fe8c8c8cea4d198fb60 Mon Sep 17 00:00:00 2001 From: Stackie Jia Date: Mon, 3 Aug 2026 19:18:06 +0800 Subject: [PATCH 3/4] fix(rtsp): address NAT mode review feedback --- docs/en/reference/configuration.md | 11 +++---- docs/reference/configuration.md | 11 +++---- e2e/test_rtsp_zte_nat.py | 32 ++++++++++++++----- ikuai-support/rtp2httpd/app/option.json | 6 ++-- ikuai-support/rtp2httpd/scripts/start.sh | 1 + .../luci-static/resources/view/rtp2httpd.js | 6 ++-- .../po/templates/rtp2httpd.pot | 4 +-- .../po/zh_Hans/rtp2httpd.po | 8 ++--- .../rtp2httpd/files/rtp2httpd.conf | 2 +- rtp2httpd.conf | 7 ++-- src/configuration.c | 18 ++++++----- src/configuration.h | 5 ++- 12 files changed, 63 insertions(+), 48 deletions(-) diff --git a/docs/en/reference/configuration.md b/docs/en/reference/configuration.md index 324dd199..3c5fd1a9 100644 --- a/docs/en/reference/configuration.md +++ b/docs/en/reference/configuration.md @@ -108,11 +108,10 @@ Unix socket listen paths must be absolute and must not contain whitespace. At st - Format: `host:port` or `host` (default port: 3478) - Example: `stun.miwifi.com` or `stun.miwifi.com:3478` -- `--rtsp-nat-mode ` - RTSP NAT traversal mode - - `none`: Disable STUN and ZTE traversal +- `--rtsp-nat-mode ` - RTSP NAT traversal mode + - `auto`: Use STUN when `rtsp-stun-server` is configured; otherwise disable NAT traversal (default) - `stun`: Use `rtsp-stun-server` to discover the public UDP port; a valid STUN server is required - `zte`: Use the ZTE `ZXV10STB` UDP punch protocol without contacting the configured STUN server - - When omitted, legacy configurations implicitly use `stun` if `rtsp-stun-server` is set, or `none` otherwise - ZTE mode supports IPv4 only. Its address comes from the actual local endpoint of the connected RTSP TCP socket, so it follows the priority of `r2h-ifname`, `upstream-interface-rtsp`, `upstream-interface`, and the system routing table ### Other Options @@ -252,9 +251,9 @@ http-proxy-user-agent = rtp2httpd-http-proxy/1.0 # Configure this when an upstream RTSP server requires a specific User-Agent for compatibility rtsp-user-agent = rtp2httpd/custom -# RTSP NAT traversal mode: none, stun, or zte (default: none) -# If omitted while rtsp-stun-server is configured, stun is selected for backward compatibility -rtsp-nat-mode = none +# RTSP NAT traversal mode: auto, stun, or zte (default: auto) +# Auto uses STUN when rtsp-stun-server is configured; otherwise NAT traversal is disabled +rtsp-nat-mode = auto # STUN server for RTSP NAT traversal (default: disabled) # When an RTSP server only supports UDP transport and the client is behind NAT, STUN can be used to attempt NAT traversal (not guaranteed to succeed) diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index db9c8d9a..f4c0101f 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -108,11 +108,10 @@ Unix socket 监听路径必须是绝对路径,且路径中不能包含空白 - 格式:`host:port` 或 `host`(默认端口 3478) - 示例:`stun.miwifi.com` 或 `stun.miwifi.com:3478` -- `--rtsp-nat-mode ` - RTSP NAT 穿透模式 - - `none`:禁用 STUN 和 ZTE 穿透 +- `--rtsp-nat-mode ` - RTSP NAT 穿透模式 + - `auto`:配置了 `rtsp-stun-server` 时使用 STUN,否则不启用 NAT 穿透(默认) - `stun`:使用 `rtsp-stun-server` 探测公网 UDP 端口;必须配置有效的 STUN 服务器 - `zte`:使用中兴 `ZXV10STB` UDP 打洞协议,不访问已配置的 STUN 服务器 - - 未显式设置时,为兼容旧配置,存在 `rtsp-stun-server` 即使用 `stun`,否则使用 `none` - ZTE 模式仅支持 IPv4。其地址取自 RTSP TCP 连接成功后的实际本地 endpoint,因此会遵循 `r2h-ifname`、`upstream-interface-rtsp`、`upstream-interface` 和系统路由表的优先级 ### 其他 @@ -250,9 +249,9 @@ http-proxy-user-agent = rtp2httpd-http-proxy/1.0 # 当上游 RTSP 服务器要求特定 User-Agent 时可配置此项 rtsp-user-agent = rtp2httpd/custom -# RTSP NAT 穿透模式:none、stun 或 zte(默认: none) -# 未设置此项但配置了 rtsp-stun-server 时,自动使用 stun 以兼容旧配置 -rtsp-nat-mode = none +# RTSP NAT 穿透模式:auto、stun 或 zte(默认: auto) +# auto 在配置了 rtsp-stun-server 时使用 STUN,否则不启用 NAT 穿透 +rtsp-nat-mode = auto # STUN 服务器用于 RTSP NAT 穿透(默认: 禁用) # 当 RTSP 服务器仅支持 UDP 传输且客户端位于 NAT 后时,可尝试使用 STUN 进行 NAT 穿透(不保证成功) diff --git a/e2e/test_rtsp_zte_nat.py b/e2e/test_rtsp_zte_nat.py index ba49bf28..d0dcde16 100644 --- a/e2e/test_rtsp_zte_nat.py +++ b/e2e/test_rtsp_zte_nat.py @@ -211,8 +211,9 @@ def test_config_file_enables_zte(self, r2h_binary): r2h.stop() rtsp.stop() - def test_explicit_none_disables_legacy_stun_inference(self, r2h_binary): - stun = MockSTUNServer() + def test_explicit_auto_uses_configured_stun(self, r2h_binary): + mapped_port = 50004 + stun = MockSTUNServer(mapped_port=mapped_port) rtsp = MockRTSPServer(num_packets=500) stun.start() rtsp.start() @@ -222,7 +223,7 @@ def test_explicit_none_disables_legacy_stun_inference(self, r2h_binary): r2h_port, extra_args=[ "--rtsp-nat-mode", - "none", + "auto", "--rtsp-stun-server", "127.0.0.1:%d" % stun.port, ], @@ -238,9 +239,9 @@ def test_explicit_none_disables_legacy_stun_inference(self, r2h_binary): ) assert status == 200 assert body - time.sleep(0.1) - assert stun.requests_received == 0 - assert "x-NAT" not in _request(rtsp, "DESCRIBE")["headers"] + assert stun.requests_received >= 1 + transport = _request(rtsp, "SETUP")["headers"]["Transport"] + assert "client_port=%d-%d" % (mapped_port, mapped_port + 1) in transport finally: r2h.stop() rtsp.stop() @@ -261,7 +262,22 @@ def test_explicit_stun_requires_server(self, r2h_binary): finally: r2h.stop() - def test_cli_mode_override_survives_reload(self, r2h_binary): + def test_none_mode_is_rejected(self, r2h_binary): + r2h = R2HProcess( + r2h_binary, + find_free_port(), + extra_args=["--rtsp-nat-mode", "none"], + capture_log=True, + ) + r2h.start(wait=False) + try: + assert r2h.process is not None + assert r2h.process.wait(timeout=5) != 0 + assert "expected auto, stun, or zte" in r2h.read_log() + finally: + r2h.stop() + + def test_cli_auto_override_survives_reload(self, r2h_binary): rtsp = MockRTSPServer(num_packets=300) rtsp.start() r2h_port = find_free_port() @@ -269,7 +285,7 @@ def test_cli_mode_override_survives_reload(self, r2h_binary): r2h_binary, r2h_port, config_content=build_config(r2h_port, global_lines=["rtsp-nat-mode = zte"]), - extra_args=["--rtsp-nat-mode", "none"], + extra_args=["--rtsp-nat-mode", "auto"], capture_log=True, ) r2h.start() diff --git a/ikuai-support/rtp2httpd/app/option.json b/ikuai-support/rtp2httpd/app/option.json index 5e9f1fc1..143dbb2d 100644 --- a/ikuai-support/rtp2httpd/app/option.json +++ b/ikuai-support/rtp2httpd/app/option.json @@ -299,11 +299,11 @@ "max": 1 }, { - "default": "none", + "default": "auto", "attrname": "RTP2HTTPD_RTSP_NAT_MODE", "label": { - "en": "RTSP NAT mode: none, stun, or zte", - "zh": "RTSP NAT 模式:none、stun 或 zte" + "en": "RTSP NAT mode: auto, stun, or zte", + "zh": "RTSP NAT 模式:auto、stun 或 zte" }, "required": true, "scope": "config", diff --git a/ikuai-support/rtp2httpd/scripts/start.sh b/ikuai-support/rtp2httpd/scripts/start.sh index fb3de600..c3276e4f 100755 --- a/ikuai-support/rtp2httpd/scripts/start.sh +++ b/ikuai-support/rtp2httpd/scripts/start.sh @@ -95,6 +95,7 @@ load_env_file "$RUNTIME_ENV" : "${RTP2HTTPD_MCAST_REJOIN_INTERVAL:=0}" : "${RTP2HTTPD_FCC_LISTEN_PORT_RANGE:=}" : "${RTP2HTTPD_ZEROCOPY_ON_SEND:=0}" +: "${RTP2HTTPD_RTSP_NAT_MODE:=auto}" : "${RTP2HTTPD_RTSP_STUN_SERVER:=}" : "${RTP2HTTPD_EXTERNAL_M3U:=}" : "${RTP2HTTPD_EXTERNAL_M3U_UPDATE_INTERVAL:=7200}" diff --git a/openwrt-support/luci-app-rtp2httpd/htdocs/luci-static/resources/view/rtp2httpd.js b/openwrt-support/luci-app-rtp2httpd/htdocs/luci-static/resources/view/rtp2httpd.js index d7b8e486..41858931 100644 --- a/openwrt-support/luci-app-rtp2httpd/htdocs/luci-static/resources/view/rtp2httpd.js +++ b/openwrt-support/luci-app-rtp2httpd/htdocs/luci-static/resources/view/rtp2httpd.js @@ -640,13 +640,13 @@ return view.extend({ "rtsp_nat_mode", _("RTSP NAT Traversal Mode"), _( - "Select none to disable traversal, STUN for standard address discovery, or ZTE for the ZXV10STB UDP punch protocol. ZTE uses the actual IPv4 endpoint selected for the RTSP TCP connection and ignores the STUN server." + "Select Auto to use STUN when a server is configured and otherwise disable traversal, STUN to require standard address discovery, or ZTE for the ZXV10STB UDP punch protocol. ZTE uses the actual IPv4 endpoint selected for the RTSP TCP connection and ignores the STUN server." ) ); - o.value("none", _("None")); + o.value("auto", _("Auto")); o.value("stun", _("STUN")); o.value("zte", _("ZTE")); - o.default = "none"; + o.default = "auto"; o.depends("use_config_file", "0"); o = s.taboption( diff --git a/openwrt-support/luci-app-rtp2httpd/po/templates/rtp2httpd.pot b/openwrt-support/luci-app-rtp2httpd/po/templates/rtp2httpd.pot index 818db230..f003b988 100644 --- a/openwrt-support/luci-app-rtp2httpd/po/templates/rtp2httpd.pot +++ b/openwrt-support/luci-app-rtp2httpd/po/templates/rtp2httpd.pot @@ -415,11 +415,11 @@ msgid "RTSP NAT Traversal Mode" msgstr "" #: htdocs/luci-static/resources/view/rtp2httpd.js:646 -msgid "Select none to disable traversal, STUN for standard address discovery, or ZTE for the ZXV10STB UDP punch protocol. ZTE uses the actual IPv4 endpoint selected for the RTSP TCP connection and ignores the STUN server." +msgid "Select Auto to use STUN when a server is configured and otherwise disable traversal, STUN to require standard address discovery, or ZTE for the ZXV10STB UDP punch protocol. ZTE uses the actual IPv4 endpoint selected for the RTSP TCP connection and ignores the STUN server." msgstr "" #: htdocs/luci-static/resources/view/rtp2httpd.js:650 -msgid "None" +msgid "Auto" msgstr "" #: htdocs/luci-static/resources/view/rtp2httpd.js:651 diff --git a/openwrt-support/luci-app-rtp2httpd/po/zh_Hans/rtp2httpd.po b/openwrt-support/luci-app-rtp2httpd/po/zh_Hans/rtp2httpd.po index 3cd46096..09eef8ff 100644 --- a/openwrt-support/luci-app-rtp2httpd/po/zh_Hans/rtp2httpd.po +++ b/openwrt-support/luci-app-rtp2httpd/po/zh_Hans/rtp2httpd.po @@ -462,12 +462,12 @@ msgid "RTSP NAT Traversal Mode" msgstr "RTSP NAT 穿透模式" #: htdocs/luci-static/resources/view/rtp2httpd.js:646 -msgid "Select none to disable traversal, STUN for standard address discovery, or ZTE for the ZXV10STB UDP punch protocol. ZTE uses the actual IPv4 endpoint selected for the RTSP TCP connection and ignores the STUN server." -msgstr "选择 none 可禁用穿透,选择 STUN 可进行标准地址探测,选择 ZTE 可使用 ZXV10STB UDP 打洞协议。ZTE 使用 RTSP TCP 连接实际选择的 IPv4 endpoint,并忽略 STUN 服务器。" +msgid "Select Auto to use STUN when a server is configured and otherwise disable traversal, STUN to require standard address discovery, or ZTE for the ZXV10STB UDP punch protocol. ZTE uses the actual IPv4 endpoint selected for the RTSP TCP connection and ignores the STUN server." +msgstr "选择 Auto 可在配置了服务器时使用 STUN,否则禁用穿透;选择 STUN 可强制进行标准地址探测;选择 ZTE 可使用 ZXV10STB UDP 打洞协议。ZTE 使用 RTSP TCP 连接实际选择的 IPv4 endpoint,并忽略 STUN 服务器。" #: htdocs/luci-static/resources/view/rtp2httpd.js:650 -msgid "None" -msgstr "无" +msgid "Auto" +msgstr "自动" #: htdocs/luci-static/resources/view/rtp2httpd.js:651 msgid "STUN" diff --git a/openwrt-support/rtp2httpd/files/rtp2httpd.conf b/openwrt-support/rtp2httpd/files/rtp2httpd.conf index 334c1cd0..473268fe 100644 --- a/openwrt-support/rtp2httpd/files/rtp2httpd.conf +++ b/openwrt-support/rtp2httpd/files/rtp2httpd.conf @@ -47,7 +47,7 @@ config rtp2httpd # option zerocopy_on_send '0' # option http_proxy_user_agent 'rtp2httpd-http-proxy/1.0' # option rtsp_user_agent 'rtp2httpd/custom' - # option rtsp_nat_mode 'none' + # option rtsp_nat_mode 'auto' # option rtsp_stun_server 'stun.miwifi.com' # option cors_allow_origin '*' # option ffmpeg_path 'ffmpeg' diff --git a/rtp2httpd.conf b/rtp2httpd.conf index dae12508..33648837 100644 --- a/rtp2httpd.conf +++ b/rtp2httpd.conf @@ -119,10 +119,9 @@ verbosity = 1 # User-Agent header used for upstream RTSP requests (default: rtp2httpd/) ;rtsp-user-agent = rtp2httpd/custom -# RTSP NAT traversal mode: none, stun, or zte (default: none) -# For backward compatibility, omitting this option enables STUN automatically -# when rtsp-stun-server is configured. -;rtsp-nat-mode = none +# RTSP NAT traversal mode: auto, stun, or zte (default: auto) +# Auto uses STUN when rtsp-stun-server is configured; otherwise NAT traversal is disabled. +;rtsp-nat-mode = auto # STUN server for RTSP NAT traversal (default: disabled) # When RTSP server only supports UDP transport and client is behind NAT, diff --git a/src/configuration.c b/src/configuration.c index 13f06f71..b6342282 100644 --- a/src/configuration.c +++ b/src/configuration.c @@ -99,8 +99,8 @@ static int parse_bool(const char *value) { } static rtsp_nat_mode_t parse_rtsp_nat_mode(const char *value) { - if (strcasecmp(value, "none") == 0) - return RTSP_NAT_MODE_NONE; + if (strcasecmp(value, "auto") == 0) + return RTSP_NAT_MODE_AUTO; if (strcasecmp(value, "stun") == 0) return RTSP_NAT_MODE_STUN; if (strcasecmp(value, "zte") == 0) @@ -112,7 +112,6 @@ rtsp_nat_mode_t config_get_effective_rtsp_nat_mode(void) { if (config.rtsp_nat_mode == RTSP_NAT_MODE_AUTO) { if (config.rtsp_stun_server && config.rtsp_stun_server[0] != '\0') return RTSP_NAT_MODE_STUN; - return RTSP_NAT_MODE_NONE; } return config.rtsp_nat_mode; } @@ -122,7 +121,7 @@ static int validate_rtsp_nat_config(void) { rtsp_nat_mode_t mode = config_get_effective_rtsp_nat_mode(); if (mode == RTSP_NAT_MODE_INVALID) { - logger(LOG_ERROR, "Invalid rtsp-nat-mode (expected none, stun, or zte)"); + logger(LOG_ERROR, "Invalid rtsp-nat-mode (expected auto, stun, or zte)"); return -1; } if (mode == RTSP_NAT_MODE_STUN && (!config.rtsp_stun_server || config.rtsp_stun_server[0] == '\0')) { @@ -1309,8 +1308,11 @@ int config_reload(int *out_bind_changed) { return -1; } - if (validate_rtsp_nat_config() < 0) + if (validate_rtsp_nat_config() < 0) { + if (old_bind_addresses) + free_bindaddr(old_bind_addresses); return -1; + } apply_bind_side_effects(); @@ -1404,8 +1406,8 @@ void usage(FILE *f, char *progname) { "(default: rtp2httpd/)\n" "\t-N --rtsp-stun-server STUN server for RTSP NAT traversal " "(default: disabled)\n" - "\t --rtsp-nat-mode RTSP NAT traversal mode " - "(default: infer stun when rtsp-stun-server is set)\n" + "\t --rtsp-nat-mode RTSP NAT traversal mode " + "(default: auto)\n" "\t-O --cors-allow-origin Set Access-Control-Allow-Origin header " "(default: disabled)\n" "\t --access-log Write access logs to this file (default: disabled)\n" @@ -1706,7 +1708,7 @@ void parse_cmd_line(int argc, char *argv[]) { case OPT_RTSP_NAT_MODE: config.rtsp_nat_mode = parse_rtsp_nat_mode(optarg); if (config.rtsp_nat_mode == RTSP_NAT_MODE_INVALID) { - logger(LOG_FATAL, "Invalid --rtsp-nat-mode value: %s (expected none, stun, or zte)", optarg); + logger(LOG_FATAL, "Invalid --rtsp-nat-mode value: %s (expected auto, stun, or zte)", optarg); exit(EXIT_FAILURE); } cmd_rtsp_nat_mode_set = 1; diff --git a/src/configuration.h b/src/configuration.h index 8ee283e7..c568c047 100644 --- a/src/configuration.h +++ b/src/configuration.h @@ -29,7 +29,6 @@ typedef enum { BIND_ADDR_TCP = 0, BIND_ADDR_UNIX } bindaddr_type_t; typedef enum { RTSP_NAT_MODE_INVALID = -1, RTSP_NAT_MODE_AUTO = 0, - RTSP_NAT_MODE_NONE, RTSP_NAT_MODE_STUN, RTSP_NAT_MODE_ZTE } rtsp_nat_mode_t; @@ -127,7 +126,7 @@ typedef struct { 1=enabled) */ /* RTSP NAT traversal settings */ - rtsp_nat_mode_t rtsp_nat_mode; /* Explicit NAT traversal mode, or AUTO for legacy inference */ + rtsp_nat_mode_t rtsp_nat_mode; /* RTSP NAT traversal mode (AUTO by default) */ char *rtsp_stun_server; /* STUN server host:port for RTSP NAT traversal (NULL=disabled) */ char *http_proxy_user_agent; /* Override User-Agent header for upstream HTTP @@ -243,7 +242,7 @@ int bind_addresses_equal(bindaddr_t *a, bindaddr_t *b); int bind_addresses_has_unix(void); /** - * Resolve the configured RTSP NAT mode, including legacy STUN inference. + * Resolve AUTO to STUN when an RTSP STUN server is configured. */ rtsp_nat_mode_t config_get_effective_rtsp_nat_mode(void); From a384cf96db216cae697d18be165669d7690b107f Mon Sep 17 00:00:00 2001 From: Stackie Jia Date: Thu, 6 Aug 2026 19:08:12 +0800 Subject: [PATCH 4/4] feat(rtsp): always apply ZTE NAT traversal Drop the rtsp-nat-mode switch and make the ZTE behaviours the default for IPv4 upstream control connections: - send the x-NAT header on DESCRIBE and SETUP, and client_address/mode=PLAY on the UDP transport candidates - replace the generic RTP hole-punch payload with the 84-byte ZXV10STB packet, keeping the existing 3x burst and per-keepalive retransmission - resolve the advertised endpoint once in rtsp_nat_endpoint(): the STUN-discovered public mapping when available, otherwise the control connection's own local endpoint STUN discovery now has to settle before DESCRIBE is sent, since DESCRIBE already carries x-NAT. The session parks in AWAITING_OPTIONS behind an explicit describe_waiting_for_stun flag; keying the resume on the state alone would pipeline DESCRIBE behind an unanswered OPTIONS. The handshake timeout is suspended while parked so STUN's retry budget cannot trip it. The feature is transparent to users, so it is not documented. Also fix config_reload() leaking the partially parsed bind address list on its failure path, and stop the mock RTSP server from folding pipelined requests into one parsed request, which had masked the DESCRIBE pipelining. --- docs/en/reference/configuration.md | 10 - docs/reference/configuration.md | 10 - e2e/helpers/mock_rtsp.py | 43 +- e2e/test_rtsp_zte_nat.py | 250 ++++---- ikuai-support/rtp2httpd/app/option.json | 13 - ikuai-support/rtp2httpd/scripts/start.sh | 4 - .../luci-static/resources/view/rtp2httpd.js | 15 - .../po/templates/rtp2httpd.pot | 20 - .../po/zh_Hans/rtp2httpd.po | 20 - .../rtp2httpd/files/rtp2httpd.conf | 1 - .../rtp2httpd/files/rtp2httpd.init | 1 - rtp2httpd.conf | 4 - src/configuration.c | 95 +--- src/configuration.h | 25 +- src/rtsp.c | 533 +++++++++++------- src/rtsp.h | 40 +- src/stun.c | 24 +- src/stun.h | 9 + src/utils.c | 25 + src/utils.h | 18 + 20 files changed, 589 insertions(+), 571 deletions(-) diff --git a/docs/en/reference/configuration.md b/docs/en/reference/configuration.md index 3c5fd1a9..b606c6e1 100644 --- a/docs/en/reference/configuration.md +++ b/docs/en/reference/configuration.md @@ -108,12 +108,6 @@ Unix socket listen paths must be absolute and must not contain whitespace. At st - Format: `host:port` or `host` (default port: 3478) - Example: `stun.miwifi.com` or `stun.miwifi.com:3478` -- `--rtsp-nat-mode ` - RTSP NAT traversal mode - - `auto`: Use STUN when `rtsp-stun-server` is configured; otherwise disable NAT traversal (default) - - `stun`: Use `rtsp-stun-server` to discover the public UDP port; a valid STUN server is required - - `zte`: Use the ZTE `ZXV10STB` UDP punch protocol without contacting the configured STUN server - - ZTE mode supports IPv4 only. Its address comes from the actual local endpoint of the connected RTSP TCP socket, so it follows the priority of `r2h-ifname`, `upstream-interface-rtsp`, `upstream-interface`, and the system routing table - ### Other Options - `-S, --video-snapshot` - Enable video snapshot feature (default: disabled) @@ -251,10 +245,6 @@ http-proxy-user-agent = rtp2httpd-http-proxy/1.0 # Configure this when an upstream RTSP server requires a specific User-Agent for compatibility rtsp-user-agent = rtp2httpd/custom -# RTSP NAT traversal mode: auto, stun, or zte (default: auto) -# Auto uses STUN when rtsp-stun-server is configured; otherwise NAT traversal is disabled -rtsp-nat-mode = auto - # STUN server for RTSP NAT traversal (default: disabled) # When an RTSP server only supports UDP transport and the client is behind NAT, STUN can be used to attempt NAT traversal (not guaranteed to succeed) # Format: host:port or host (default port: 3478) diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index f4c0101f..509282ef 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -108,12 +108,6 @@ Unix socket 监听路径必须是绝对路径,且路径中不能包含空白 - 格式:`host:port` 或 `host`(默认端口 3478) - 示例:`stun.miwifi.com` 或 `stun.miwifi.com:3478` -- `--rtsp-nat-mode ` - RTSP NAT 穿透模式 - - `auto`:配置了 `rtsp-stun-server` 时使用 STUN,否则不启用 NAT 穿透(默认) - - `stun`:使用 `rtsp-stun-server` 探测公网 UDP 端口;必须配置有效的 STUN 服务器 - - `zte`:使用中兴 `ZXV10STB` UDP 打洞协议,不访问已配置的 STUN 服务器 - - ZTE 模式仅支持 IPv4。其地址取自 RTSP TCP 连接成功后的实际本地 endpoint,因此会遵循 `r2h-ifname`、`upstream-interface-rtsp`、`upstream-interface` 和系统路由表的优先级 - ### 其他 - `-S, --video-snapshot` - 启用视频快照功能 (默认: 关闭) @@ -249,10 +243,6 @@ http-proxy-user-agent = rtp2httpd-http-proxy/1.0 # 当上游 RTSP 服务器要求特定 User-Agent 时可配置此项 rtsp-user-agent = rtp2httpd/custom -# RTSP NAT 穿透模式:auto、stun 或 zte(默认: auto) -# auto 在配置了 rtsp-stun-server 时使用 STUN,否则不启用 NAT 穿透 -rtsp-nat-mode = auto - # STUN 服务器用于 RTSP NAT 穿透(默认: 禁用) # 当 RTSP 服务器仅支持 UDP 传输且客户端位于 NAT 后时,可尝试使用 STUN 进行 NAT 穿透(不保证成功) # 格式: host:port 或 host(默认端口 3478) diff --git a/e2e/helpers/mock_rtsp.py b/e2e/helpers/mock_rtsp.py index 63d9dce2..dd04ae60 100644 --- a/e2e/helpers/mock_rtsp.py +++ b/e2e/helpers/mock_rtsp.py @@ -130,14 +130,19 @@ def _handle(self, conn: socket.socket, addr: tuple) -> None: self.control_peer = addr transport_hdr = "" try: + pending = b"" while True: - data = b"" - while b"\r\n\r\n" not in data: + while b"\r\n\r\n" not in pending: chunk = conn.recv(4096) if not chunk: return - data += chunk - req = data.decode(errors="replace") + pending += chunk + # Split off exactly one request; anything left is a later + # request that arrived in the same segment. Keeping it buffered + # (rather than folding it into this one) is what makes an + # unexpectedly pipelined request visible to tests. + data, pending = pending.split(b"\r\n\r\n", 1) + req = data.decode(errors="replace") + "\r\n\r\n" first_line = req.split("\r\n")[0].split() method = first_line[0] uri = first_line[1] if len(first_line) > 1 else "" @@ -409,11 +414,28 @@ def _after_play(self, conn: socket.socket, addr: tuple) -> None: class MockRTSPServerZTE(_RTSPServerBase): - """RTSP server that starts UDP media only after a valid ZTE punch packet.""" + """RTSP server that starts UDP media only after a valid ZTE punch packet. - def __init__(self, port: int = 0, num_packets: int = 200): + ``expected_ip`` / ``expected_control_port`` override what the punch packet is + validated against; leave them unset to expect the RTSP control connection's + own endpoint. Set them when rtp2httpd advertises a STUN-discovered mapping + instead, in which case the UDP source port no longer matches the advertised + RTP port and ``check_source_port`` must be disabled. + """ + + def __init__( + self, + port: int = 0, + num_packets: int = 200, + expected_ip: str | None = None, + expected_control_port: int | None = None, + check_source_port: bool = True, + ): super().__init__(port) self._num_packets = num_packets + self._expected_ip = expected_ip + self._expected_control_port = expected_control_port + self._check_source_port = check_source_port self._server_rtp_socket: socket.socket | None = None self._server_rtcp_socket: socket.socket | None = None self._receiver_thread: threading.Thread | None = None @@ -491,8 +513,10 @@ def _receive_probes(self) -> None: def _probe_is_valid(self, payload: bytes, source: tuple) -> bool: if not self.control_peer or len(payload) != 84: return False - expected_ip = socket.inet_aton(self.control_peer[0]) - expected_tcp_port = self.control_peer[1] + expected_ip = socket.inet_aton(self._expected_ip or self.control_peer[0]) + expected_tcp_port = self._expected_control_port or self.control_peer[1] + if self._check_source_port and source[1] != self._client_rtp_port: + return False return ( payload[:8] == b"ZXV10STB" and payload[8:12] == b"\x7f\xff\xff\xff" @@ -501,7 +525,6 @@ def _probe_is_valid(self, payload: bytes, source: tuple) -> bool: and struct.unpack("!H", payload[18:20])[0] == expected_tcp_port and payload[20:] == bytes(64) and source[0] == self.control_peer[0] - and source[1] == self._client_rtp_port ) def _after_play(self, conn: socket.socket, addr: tuple) -> None: @@ -512,7 +535,7 @@ def _after_play(self, conn: socket.socket, addr: tuple) -> None: self._receiver_thread.join(timeout=0.2) assert self._server_rtp_socket is not None - destination = self.udp_datagrams[0][1] + destination = next(source for payload, source in self.udp_datagrams if self._probe_is_valid(payload, source)) seq = 0 ts = 0 try: diff --git a/e2e/test_rtsp_zte_nat.py b/e2e/test_rtsp_zte_nat.py index d0dcde16..b9993d95 100644 --- a/e2e/test_rtsp_zte_nat.py +++ b/e2e/test_rtsp_zte_nat.py @@ -1,10 +1,7 @@ -"""End-to-end coverage for the ZTE RTSP NAT traversal mode.""" +"""End-to-end coverage for the always-on ZTE RTSP NAT traversal behaviour.""" -import os -import signal import socket import struct -import time import pytest @@ -14,7 +11,6 @@ MockRTSPServerZTE, MockSTUNServer, R2HProcess, - build_config, find_free_port, ipv6_loopback_available, stream_get, @@ -28,27 +24,11 @@ def _request(server, method): class TestZTEProtocol: - def test_zte_headers_endpoint_invariants_and_probe_bytes(self, r2h_binary): - stun = MockSTUNServer() + def test_headers_endpoint_invariants_and_probe_bytes(self, r2h_binary): rtsp = MockRTSPServerZTE(num_packets=500) - stun.start() rtsp.start() r2h_port = find_free_port() - r2h = R2HProcess( - r2h_binary, - r2h_port, - extra_args=[ - "-v", - "4", - "-m", - "100", - "--rtsp-nat-mode", - "zte", - "--rtsp-stun-server", - "127.0.0.1:%d" % stun.port, - ], - capture_log=True, - ) + r2h = R2HProcess(r2h_binary, r2h_port, extra_args=["-v", "4", "-m", "100"], capture_log=True) r2h.start() try: status, _, body = stream_get( @@ -61,7 +41,6 @@ def test_zte_headers_endpoint_invariants_and_probe_bytes(self, r2h_binary): assert status == 200 assert len(body) >= 188 assert rtsp.valid_probe_received - assert stun.requests_received == 0 assert rtsp.control_peer is not None tcp_source_ip, tcp_source_port = rtsp.control_peer[:2] @@ -71,23 +50,36 @@ def test_zte_headers_endpoint_invariants_and_probe_bytes(self, r2h_binary): assert describe["headers"]["x-NAT"] == expected_x_nat assert setup["headers"]["x-NAT"] == expected_x_nat + # The full candidate list is always offered so a UDP-incapable server + # can fall back to TCP interleaved; the ZTE-specific + # client_address/mode parameters ride on the UDP alternatives only. transport = setup["headers"]["Transport"] - assert transport == ( - "MP2T/RTP/UDP;unicast;client_address=%s;client_port=%d-%d;mode=PLAY" - % (tcp_source_ip, rtsp._client_rtp_port, rtsp._client_rtp_port + 1) + assert transport == ",".join( + [ + "MP2T/RTP/TCP;unicast;interleaved=0-1", + "MP2T/TCP;unicast;interleaved=0-1", + "RTP/AVP/TCP;unicast;interleaved=0-1", + ] + + [ + "%s;unicast;client_address=%s;client_port=%d-%d;mode=PLAY" + % (profile, tcp_source_ip, rtsp._client_rtp_port, rtsp._client_rtp_port + 1) + for profile in ("MP2T/RTP/UDP", "MP2T/UDP", "RTP/AVP") + ] ) - assert len(rtsp.udp_datagrams) == 1 - payload, udp_source = rtsp.udp_datagrams[0] - assert len(payload) == 84 - assert payload[:8] == b"ZXV10STB" - assert payload[8:12] == b"\x7f\xff\xff\xff" - assert payload[12:16] == socket.inet_aton(tcp_source_ip) - assert struct.unpack("!H", payload[16:18])[0] == rtsp._client_rtp_port - assert struct.unpack("!H", payload[18:20])[0] == tcp_source_port - assert payload[20:] == bytes(64) - assert udp_source[0] == tcp_source_ip - assert udp_source[1] == rtsp._client_rtp_port + # Probes are sent three times per attempt (UDP has no delivery + # guarantee); every one of them must be a well-formed punch packet. + assert len(rtsp.udp_datagrams) >= 3 + for payload, udp_source in rtsp.udp_datagrams: + assert len(payload) == 84 + assert payload[:8] == b"ZXV10STB" + assert payload[8:12] == b"\x7f\xff\xff\xff" + assert payload[12:16] == socket.inet_aton(tcp_source_ip) + assert struct.unpack("!H", payload[16:18])[0] == rtsp._client_rtp_port + assert struct.unpack("!H", payload[18:20])[0] == tcp_source_port + assert payload[20:] == bytes(64) + assert udp_source[0] == tcp_source_ip + assert udp_source[1] == rtsp._client_rtp_port # TCP control requests and UDP probes are consumed by separate mock # threads, so their server-side observation order is inherently @@ -95,21 +87,17 @@ def test_zte_headers_endpoint_invariants_and_probe_bytes(self, r2h_binary): # response, and MockRTSPServerZTE withholds media until this exact # packet validates, which covers the protocol dependency without a # cross-protocol scheduling assertion. - - log = r2h.read_log() - assert "RTSP STUN server ignored because rtsp-nat-mode=zte" in log - assert "RTSP: Upstream interface route-selected, local endpoint %s:" % tcp_source_ip in log + assert "RTSP: Upstream interface route-selected, local endpoint %s:" % tcp_source_ip in r2h.read_log() finally: r2h.stop() rtsp.stop() - stun.stop() @pytest.mark.parametrize("interface_source", ["global", "request"]) def test_interface_selection_keeps_tcp_and_udp_source_aligned(self, r2h_binary, interface_source): rtsp = MockRTSPServerZTE(num_packets=200) rtsp.start() r2h_port = find_free_port() - extra_args = ["--rtsp-nat-mode", "zte"] + extra_args = [] path = "/rtsp/127.0.0.1:%d/stream" % rtsp.port if interface_source == "global": extra_args.extend(["--upstream-interface-rtsp", LOOPBACK_IF]) @@ -128,18 +116,45 @@ def test_interface_selection_keeps_tcp_and_udp_source_aligned(self, r2h_binary, r2h.stop() rtsp.stop() + def test_tcp_only_server_falls_back_to_interleaved_without_probe(self, r2h_binary): + """The server picks the transport; UDP is never forced.""" + rtsp = MockRTSPServer(num_packets=300) + rtsp.start() + r2h_port = find_free_port() + r2h = R2HProcess(r2h_binary, r2h_port, extra_args=["-v", "4"], capture_log=True) + r2h.start() + try: + status, _, body = stream_get( + "127.0.0.1", + r2h_port, + "/rtsp/127.0.0.1:%d/stream" % rtsp.port, + read_bytes=188, + timeout=20.0, + ) + assert status == 200 + assert body + + setup = _request(rtsp, "SETUP") + transport = setup["headers"]["Transport"] + # Both families are offered; x-NAT still rides along for ZTE servers + assert "interleaved=0-1" in transport + assert "MP2T/RTP/UDP;unicast;client_address=" in transport + assert "x-NAT" in setup["headers"] + + log = r2h.read_log() + assert "Using TCP interleaved transport" in log + assert "NAT probe" not in log + finally: + r2h.stop() + rtsp.stop() + def test_ipv6_upstream_falls_back_to_ordinary_rtsp(self, r2h_binary): if not ipv6_loopback_available(): pytest.skip("IPv6 loopback is unavailable") rtsp = MockRTSPServer(num_packets=300, host="::1") rtsp.start() r2h_port = find_free_port() - r2h = R2HProcess( - r2h_binary, - r2h_port, - extra_args=["-v", "4", "--rtsp-nat-mode", "zte"], - capture_log=True, - ) + r2h = R2HProcess(r2h_binary, r2h_port, extra_args=["-v", "4"], capture_log=True) r2h.start() try: status, _, body = stream_get( @@ -152,7 +167,8 @@ def test_ipv6_upstream_falls_back_to_ordinary_rtsp(self, r2h_binary): assert status == 200 assert body assert "x-NAT" not in _request(rtsp, "DESCRIBE")["headers"] - assert "ZTE NAT traversal only supports IPv4" in r2h.read_log() + assert "client_address=" not in _request(rtsp, "SETUP")["headers"]["Transport"] + assert "IPv6 control connection, skipping ZTE NAT traversal" in r2h.read_log() finally: r2h.stop() rtsp.stop() @@ -163,7 +179,7 @@ def test_redirect_recaptures_control_endpoint(self, r2h_binary): redirect = MockRTSPServer(redirect_describe_to="rtsp://127.0.0.1:%d/stream" % target.port) redirect.start() r2h_port = find_free_port() - r2h = R2HProcess(r2h_binary, r2h_port, extra_args=["--rtsp-nat-mode", "zte"]) + r2h = R2HProcess(r2h_binary, r2h_port) r2h.start() try: status, _, body = stream_get( @@ -185,15 +201,28 @@ def test_redirect_recaptures_control_endpoint(self, r2h_binary): target.stop() -class TestNATModeCompatibility: - def test_config_file_enables_zte(self, r2h_binary): - rtsp = MockRTSPServerZTE(num_packets=200) +class TestSTUNInteraction: + def test_stun_mapping_is_advertised_in_x_nat_and_client_address(self, r2h_binary): + """With STUN configured, the discovered public mapping wins everywhere.""" + mapped_ip = "203.0.113.7" + mapped_port = 50006 + stun = MockSTUNServer(mapped_ip=mapped_ip, mapped_port=mapped_port) + # The punch packet now carries the STUN mapping, and the UDP source port + # is the real local port rather than the advertised one. + rtsp = MockRTSPServerZTE( + num_packets=300, + expected_ip=mapped_ip, + expected_control_port=mapped_port, + check_source_port=False, + ) + stun.start() rtsp.start() r2h_port = find_free_port() r2h = R2HProcess( r2h_binary, r2h_port, - config_content=build_config(r2h_port, global_lines=["rtsp-nat-mode = zte"]), + extra_args=["-v", "4", "--rtsp-stun-server", "127.0.0.1:%d" % stun.port], + capture_log=True, ) r2h.start() try: @@ -206,27 +235,34 @@ def test_config_file_enables_zte(self, r2h_binary): ) assert status == 200 assert body + assert stun.requests_received >= 1 assert rtsp.valid_probe_received + + expected_x_nat = "%s:%d" % (mapped_ip, mapped_port) + # DESCRIBE is held back until STUN settles, so it already carries + # the public mapping rather than the private endpoint. + assert _request(rtsp, "DESCRIBE")["headers"]["x-NAT"] == expected_x_nat + setup = _request(rtsp, "SETUP") + assert setup["headers"]["x-NAT"] == expected_x_nat + transport = setup["headers"]["Transport"] + assert "client_address=%s;client_port=%d-%d" % (mapped_ip, mapped_port, mapped_port + 1) in transport finally: r2h.stop() rtsp.stop() + stun.stop() - def test_explicit_auto_uses_configured_stun(self, r2h_binary): - mapped_port = 50004 - stun = MockSTUNServer(mapped_port=mapped_port) - rtsp = MockRTSPServer(num_packets=500) + def test_silent_stun_falls_back_to_local_endpoint(self, r2h_binary): + """A dead STUN server must not stall the handshake past its own budget.""" + stun = MockSTUNServer(silent=True) + rtsp = MockRTSPServerZTE(num_packets=300) stun.start() rtsp.start() r2h_port = find_free_port() r2h = R2HProcess( r2h_binary, r2h_port, - extra_args=[ - "--rtsp-nat-mode", - "auto", - "--rtsp-stun-server", - "127.0.0.1:%d" % stun.port, - ], + extra_args=["-v", "4", "--rtsp-stun-server", "127.0.0.1:%d" % stun.port], + capture_log=True, ) r2h.start() try: @@ -234,79 +270,23 @@ def test_explicit_auto_uses_configured_stun(self, r2h_binary): "127.0.0.1", r2h_port, "/rtsp/127.0.0.1:%d/stream" % rtsp.port, - read_bytes=4096, - timeout=20.0, + read_bytes=188, + timeout=30.0, ) assert status == 200 assert body - assert stun.requests_received >= 1 - transport = _request(rtsp, "SETUP")["headers"]["Transport"] - assert "client_port=%d-%d" % (mapped_port, mapped_port + 1) in transport - finally: - r2h.stop() - rtsp.stop() - stun.stop() - - def test_explicit_stun_requires_server(self, r2h_binary): - r2h = R2HProcess( - r2h_binary, - find_free_port(), - extra_args=["--rtsp-nat-mode", "stun"], - capture_log=True, - ) - r2h.start(wait=False) - try: - assert r2h.process is not None - assert r2h.process.wait(timeout=5) != 0 - assert "rtsp-nat-mode=stun requires rtsp-stun-server" in r2h.read_log() - finally: - r2h.stop() - - def test_none_mode_is_rejected(self, r2h_binary): - r2h = R2HProcess( - r2h_binary, - find_free_port(), - extra_args=["--rtsp-nat-mode", "none"], - capture_log=True, - ) - r2h.start(wait=False) - try: - assert r2h.process is not None - assert r2h.process.wait(timeout=5) != 0 - assert "expected auto, stun, or zte" in r2h.read_log() - finally: - r2h.stop() - - def test_cli_auto_override_survives_reload(self, r2h_binary): - rtsp = MockRTSPServer(num_packets=300) - rtsp.start() - r2h_port = find_free_port() - r2h = R2HProcess( - r2h_binary, - r2h_port, - config_content=build_config(r2h_port, global_lines=["rtsp-nat-mode = zte"]), - extra_args=["--rtsp-nat-mode", "auto"], - capture_log=True, - ) - r2h.start() - try: - path = "/rtsp/127.0.0.1:%d/stream" % rtsp.port - status, _, body = stream_get("127.0.0.1", r2h_port, path, read_bytes=188, timeout=20.0) - assert status == 200 - assert body - - assert r2h.process is not None - os.kill(r2h.process.pid, signal.SIGHUP) - deadline = time.time() + 5.0 - while "Configuration reloaded successfully" not in r2h.read_log() and time.time() < deadline: - time.sleep(0.05) + assert rtsp.valid_probe_received + assert rtsp.control_peer is not None + assert _request(rtsp, "DESCRIBE")["headers"]["x-NAT"] == "%s:%d" % rtsp.control_peer[:2] - status, _, body = stream_get("127.0.0.1", r2h_port, path, read_bytes=188, timeout=20.0) - assert status == 200 - assert body - describe_requests = [request for request in rtsp.requests_detailed if request["method"] == "DESCRIBE"] - assert len(describe_requests) >= 2 - assert all("x-NAT" not in request["headers"] for request in describe_requests) + log = r2h.read_log() + # DESCRIBE really was parked, and STUN's ~3s retry budget did not + # trip the 3s handshake timeout. + assert "Waiting for STUN response before sending DESCRIBE" in log + assert "STUN: Timeout after 3 attempts" in log + # Parking must not pipeline DESCRIBE behind an unanswered OPTIONS. + assert rtsp.requests_received[:4] == ["OPTIONS", "DESCRIBE", "SETUP", "PLAY"] finally: r2h.stop() rtsp.stop() + stun.stop() diff --git a/ikuai-support/rtp2httpd/app/option.json b/ikuai-support/rtp2httpd/app/option.json index 143dbb2d..6077d3ba 100644 --- a/ikuai-support/rtp2httpd/app/option.json +++ b/ikuai-support/rtp2httpd/app/option.json @@ -298,19 +298,6 @@ "min": 0, "max": 1 }, - { - "default": "auto", - "attrname": "RTP2HTTPD_RTSP_NAT_MODE", - "label": { - "en": "RTSP NAT mode: auto, stun, or zte", - "zh": "RTSP NAT 模式:auto、stun 或 zte" - }, - "required": true, - "scope": "config", - "type": "text", - "min": 3, - "max": 4 - }, { "default": "", "attrname": "RTP2HTTPD_RTSP_STUN_SERVER", diff --git a/ikuai-support/rtp2httpd/scripts/start.sh b/ikuai-support/rtp2httpd/scripts/start.sh index c3276e4f..3c0099ec 100755 --- a/ikuai-support/rtp2httpd/scripts/start.sh +++ b/ikuai-support/rtp2httpd/scripts/start.sh @@ -95,7 +95,6 @@ load_env_file "$RUNTIME_ENV" : "${RTP2HTTPD_MCAST_REJOIN_INTERVAL:=0}" : "${RTP2HTTPD_FCC_LISTEN_PORT_RANGE:=}" : "${RTP2HTTPD_ZEROCOPY_ON_SEND:=0}" -: "${RTP2HTTPD_RTSP_NAT_MODE:=auto}" : "${RTP2HTTPD_RTSP_STUN_SERVER:=}" : "${RTP2HTTPD_EXTERNAL_M3U:=}" : "${RTP2HTTPD_EXTERNAL_M3U_UPDATE_INTERVAL:=7200}" @@ -162,9 +161,6 @@ fi if [ "$RTP2HTTPD_ZEROCOPY_ON_SEND" = "1" ]; then set -- "$@" --zerocopy-on-send fi -if [ -n "$RTP2HTTPD_RTSP_NAT_MODE" ]; then - set -- "$@" --rtsp-nat-mode "$RTP2HTTPD_RTSP_NAT_MODE" -fi if [ -n "$RTP2HTTPD_RTSP_STUN_SERVER" ]; then set -- "$@" --rtsp-stun-server "$RTP2HTTPD_RTSP_STUN_SERVER" fi diff --git a/openwrt-support/luci-app-rtp2httpd/htdocs/luci-static/resources/view/rtp2httpd.js b/openwrt-support/luci-app-rtp2httpd/htdocs/luci-static/resources/view/rtp2httpd.js index 41858931..cb36d09b 100644 --- a/openwrt-support/luci-app-rtp2httpd/htdocs/luci-static/resources/view/rtp2httpd.js +++ b/openwrt-support/luci-app-rtp2httpd/htdocs/luci-static/resources/view/rtp2httpd.js @@ -634,21 +634,6 @@ return view.extend({ o.default = "0"; o.depends("use_config_file", "0"); - o = s.taboption( - "network", - form.ListValue, - "rtsp_nat_mode", - _("RTSP NAT Traversal Mode"), - _( - "Select Auto to use STUN when a server is configured and otherwise disable traversal, STUN to require standard address discovery, or ZTE for the ZXV10STB UDP punch protocol. ZTE uses the actual IPv4 endpoint selected for the RTSP TCP connection and ignores the STUN server." - ) - ); - o.value("auto", _("Auto")); - o.value("stun", _("STUN")); - o.value("zte", _("ZTE")); - o.default = "auto"; - o.depends("use_config_file", "0"); - o = s.taboption( "network", form.Value, diff --git a/openwrt-support/luci-app-rtp2httpd/po/templates/rtp2httpd.pot b/openwrt-support/luci-app-rtp2httpd/po/templates/rtp2httpd.pot index f003b988..cb2972bd 100644 --- a/openwrt-support/luci-app-rtp2httpd/po/templates/rtp2httpd.pot +++ b/openwrt-support/luci-app-rtp2httpd/po/templates/rtp2httpd.pot @@ -410,26 +410,6 @@ msgstr "" msgid "rtp2httpd" msgstr "" -#: htdocs/luci-static/resources/view/rtp2httpd.js:644 -msgid "RTSP NAT Traversal Mode" -msgstr "" - -#: htdocs/luci-static/resources/view/rtp2httpd.js:646 -msgid "Select Auto to use STUN when a server is configured and otherwise disable traversal, STUN to require standard address discovery, or ZTE for the ZXV10STB UDP punch protocol. ZTE uses the actual IPv4 endpoint selected for the RTSP TCP connection and ignores the STUN server." -msgstr "" - -#: htdocs/luci-static/resources/view/rtp2httpd.js:650 -msgid "Auto" -msgstr "" - -#: htdocs/luci-static/resources/view/rtp2httpd.js:651 -msgid "STUN" -msgstr "" - -#: htdocs/luci-static/resources/view/rtp2httpd.js:652 -msgid "ZTE" -msgstr "" - #: htdocs/luci-static/resources/view/rtp2httpd.js:281 msgid "" "rtp2httpd converts RTP/UDP/RTSP media into http stream. Here you can " diff --git a/openwrt-support/luci-app-rtp2httpd/po/zh_Hans/rtp2httpd.po b/openwrt-support/luci-app-rtp2httpd/po/zh_Hans/rtp2httpd.po index 09eef8ff..280b0045 100644 --- a/openwrt-support/luci-app-rtp2httpd/po/zh_Hans/rtp2httpd.po +++ b/openwrt-support/luci-app-rtp2httpd/po/zh_Hans/rtp2httpd.po @@ -457,26 +457,6 @@ msgstr "启用零拷贝发送" msgid "rtp2httpd" msgstr "rtp2httpd" -#: htdocs/luci-static/resources/view/rtp2httpd.js:644 -msgid "RTSP NAT Traversal Mode" -msgstr "RTSP NAT 穿透模式" - -#: htdocs/luci-static/resources/view/rtp2httpd.js:646 -msgid "Select Auto to use STUN when a server is configured and otherwise disable traversal, STUN to require standard address discovery, or ZTE for the ZXV10STB UDP punch protocol. ZTE uses the actual IPv4 endpoint selected for the RTSP TCP connection and ignores the STUN server." -msgstr "选择 Auto 可在配置了服务器时使用 STUN,否则禁用穿透;选择 STUN 可强制进行标准地址探测;选择 ZTE 可使用 ZXV10STB UDP 打洞协议。ZTE 使用 RTSP TCP 连接实际选择的 IPv4 endpoint,并忽略 STUN 服务器。" - -#: htdocs/luci-static/resources/view/rtp2httpd.js:650 -msgid "Auto" -msgstr "自动" - -#: htdocs/luci-static/resources/view/rtp2httpd.js:651 -msgid "STUN" -msgstr "STUN" - -#: htdocs/luci-static/resources/view/rtp2httpd.js:652 -msgid "ZTE" -msgstr "ZTE" - #: htdocs/luci-static/resources/view/rtp2httpd.js:281 msgid "" "rtp2httpd converts RTP/UDP/RTSP media into http stream. Here you can " diff --git a/openwrt-support/rtp2httpd/files/rtp2httpd.conf b/openwrt-support/rtp2httpd/files/rtp2httpd.conf index 473268fe..46285fb1 100644 --- a/openwrt-support/rtp2httpd/files/rtp2httpd.conf +++ b/openwrt-support/rtp2httpd/files/rtp2httpd.conf @@ -47,7 +47,6 @@ config rtp2httpd # option zerocopy_on_send '0' # option http_proxy_user_agent 'rtp2httpd-http-proxy/1.0' # option rtsp_user_agent 'rtp2httpd/custom' - # option rtsp_nat_mode 'auto' # option rtsp_stun_server 'stun.miwifi.com' # option cors_allow_origin '*' # option ffmpeg_path 'ffmpeg' diff --git a/openwrt-support/rtp2httpd/files/rtp2httpd.init b/openwrt-support/rtp2httpd/files/rtp2httpd.init index d094d09b..b8674cfb 100644 --- a/openwrt-support/rtp2httpd/files/rtp2httpd.init +++ b/openwrt-support/rtp2httpd/files/rtp2httpd.init @@ -87,7 +87,6 @@ start_instance() { append_arg "$cfg" fcc_listen_port_range "--fcc-listen-port-range" append_arg "$cfg" http_proxy_user_agent "--http-proxy-user-agent" append_arg "$cfg" rtsp_user_agent "--rtsp-user-agent" - append_arg "$cfg" rtsp_nat_mode "--rtsp-nat-mode" append_arg "$cfg" rtsp_stun_server "--rtsp-stun-server" append_arg "$cfg" cors_allow_origin "--cors-allow-origin" append_arg "$cfg" ffmpeg_path "--ffmpeg-path" diff --git a/rtp2httpd.conf b/rtp2httpd.conf index 33648837..cffa879f 100644 --- a/rtp2httpd.conf +++ b/rtp2httpd.conf @@ -119,10 +119,6 @@ verbosity = 1 # User-Agent header used for upstream RTSP requests (default: rtp2httpd/) ;rtsp-user-agent = rtp2httpd/custom -# RTSP NAT traversal mode: auto, stun, or zte (default: auto) -# Auto uses STUN when rtsp-stun-server is configured; otherwise NAT traversal is disabled. -;rtsp-nat-mode = auto - # STUN server for RTSP NAT traversal (default: disabled) # When RTSP server only supports UDP transport and client is behind NAT, # try using STUN for NAT traversal (may not always succeed) diff --git a/src/configuration.c b/src/configuration.c index b6342282..86f8f6c8 100644 --- a/src/configuration.c +++ b/src/configuration.c @@ -50,7 +50,6 @@ int cmd_zerocopy_on_send_set = 0; int cmd_workers_set = 0; int cmd_external_m3u_url_set = 0; int cmd_external_m3u_update_interval_set = 0; -int cmd_rtsp_nat_mode_set = 0; int cmd_rtsp_stun_server_set = 0; int cmd_http_proxy_user_agent_set = 0; int cmd_rtsp_user_agent_set = 0; @@ -66,8 +65,7 @@ enum long_option_e { OPT_USE_RELATIVE_PATH_IN_M3U, OPT_ACCESS_LOG, OPT_LOG_FORMAT, - OPT_PID_FILE, - OPT_RTSP_NAT_MODE + OPT_PID_FILE }; /* M3U parsing state variables */ @@ -98,44 +96,6 @@ static int parse_bool(const char *value) { (strcasecmp("1", value) == 0); } -static rtsp_nat_mode_t parse_rtsp_nat_mode(const char *value) { - if (strcasecmp(value, "auto") == 0) - return RTSP_NAT_MODE_AUTO; - if (strcasecmp(value, "stun") == 0) - return RTSP_NAT_MODE_STUN; - if (strcasecmp(value, "zte") == 0) - return RTSP_NAT_MODE_ZTE; - return RTSP_NAT_MODE_INVALID; -} - -rtsp_nat_mode_t config_get_effective_rtsp_nat_mode(void) { - if (config.rtsp_nat_mode == RTSP_NAT_MODE_AUTO) { - if (config.rtsp_stun_server && config.rtsp_stun_server[0] != '\0') - return RTSP_NAT_MODE_STUN; - } - return config.rtsp_nat_mode; -} - -static int validate_rtsp_nat_config(void) { - static int zte_stun_warning_logged = 0; - rtsp_nat_mode_t mode = config_get_effective_rtsp_nat_mode(); - - if (mode == RTSP_NAT_MODE_INVALID) { - logger(LOG_ERROR, "Invalid rtsp-nat-mode (expected auto, stun, or zte)"); - return -1; - } - if (mode == RTSP_NAT_MODE_STUN && (!config.rtsp_stun_server || config.rtsp_stun_server[0] == '\0')) { - logger(LOG_ERROR, "rtsp-nat-mode=stun requires rtsp-stun-server"); - return -1; - } - if (mode == RTSP_NAT_MODE_ZTE && config.rtsp_stun_server && config.rtsp_stun_server[0] != '\0' && - !zte_stun_warning_logged) { - logger(LOG_WARN, "RTSP STUN server ignored because rtsp-nat-mode=zte"); - zte_stun_warning_logged = 1; - } - return 0; -} - /* Set config value if not already set by command line */ static int set_if_not_cmd_override(int cmd_flag, const char *param_name) { if (cmd_flag) { @@ -791,16 +751,7 @@ void parse_global_sec(char *line) { return; } - /* RTSP NAT traversal configuration */ - if (strcasecmp("rtsp-nat-mode", param) == 0) { - if (set_if_not_cmd_override(cmd_rtsp_nat_mode_set, "rtsp-nat-mode")) { - config.rtsp_nat_mode = parse_rtsp_nat_mode(value); - if (config.rtsp_nat_mode == RTSP_NAT_MODE_INVALID) - logger(LOG_ERROR, "Invalid rtsp-nat-mode value: %s", value); - } - return; - } - + /* STUN NAT traversal configuration */ if (strcasecmp("rtsp-stun-server", param) == 0) { if (!cmd_rtsp_stun_server_set) { safe_free_string(&config.rtsp_stun_server); @@ -1225,8 +1176,6 @@ void config_init(void) { config.zerocopy_on_send = 0; if (!cmd_use_relative_path_in_m3u_set) config.use_relative_path_in_m3u = 0; - if (!cmd_rtsp_nat_mode_set) - config.rtsp_nat_mode = RTSP_NAT_MODE_AUTO; if (!cmd_fcc_listen_port_range_set) { config.fcc_listen_port_min = 0; config.fcc_listen_port_max = 0; @@ -1298,20 +1247,7 @@ int config_reload(int *out_bind_changed) { /* Step 3: Parse config file */ if (parse_config_file(config_file_path) != 0) { logger(LOG_ERROR, "Failed to parse config file during reload: %s", config_file_path); - /* Restore old bind addresses */ - if (!cmd_bind_set) { - bind_addresses = old_bind_addresses; - old_bind_addresses = NULL; /* Don't free it */ - } - if (old_bind_addresses) - free_bindaddr(old_bind_addresses); - return -1; - } - - if (validate_rtsp_nat_config() < 0) { - if (old_bind_addresses) - free_bindaddr(old_bind_addresses); - return -1; + goto reload_failed; } apply_bind_side_effects(); @@ -1328,6 +1264,17 @@ int config_reload(int *out_bind_changed) { logger(LOG_INFO, "Configuration reloaded successfully from %s", config_file_path); return 0; + +reload_failed: + /* Restore the bind addresses captured before the failed reload */ + if (!cmd_bind_set) { + free_bindaddr(bind_addresses); + bind_addresses = old_bind_addresses; + old_bind_addresses = NULL; /* Now owned by the global */ + } + if (old_bind_addresses) + free_bindaddr(old_bind_addresses); + return -1; } void usage(FILE *f, char *progname) { @@ -1406,8 +1353,6 @@ void usage(FILE *f, char *progname) { "(default: rtp2httpd/)\n" "\t-N --rtsp-stun-server STUN server for RTSP NAT traversal " "(default: disabled)\n" - "\t --rtsp-nat-mode RTSP NAT traversal mode " - "(default: auto)\n" "\t-O --cors-allow-origin Set Access-Control-Allow-Origin header " "(default: disabled)\n" "\t --access-log Write access logs to this file (default: disabled)\n" @@ -1491,7 +1436,6 @@ void parse_cmd_line(int argc, char *argv[]) { {"zerocopy-on-send", no_argument, 0, 'Z'}, {"http-proxy-user-agent", required_argument, 0, 'g'}, {"rtsp-stun-server", required_argument, 0, 'N'}, - {"rtsp-nat-mode", required_argument, 0, OPT_RTSP_NAT_MODE}, {"rtsp-user-agent", required_argument, 0, 'u'}, {"cors-allow-origin", required_argument, 0, 'O'}, {"access-log", required_argument, 0, OPT_ACCESS_LOG}, @@ -1705,14 +1649,6 @@ void parse_cmd_line(int argc, char *argv[]) { cmd_rtsp_stun_server_set = 1; logger(LOG_INFO, "RTSP STUN server: %s", config.rtsp_stun_server); break; - case OPT_RTSP_NAT_MODE: - config.rtsp_nat_mode = parse_rtsp_nat_mode(optarg); - if (config.rtsp_nat_mode == RTSP_NAT_MODE_INVALID) { - logger(LOG_FATAL, "Invalid --rtsp-nat-mode value: %s (expected auto, stun, or zte)", optarg); - exit(EXIT_FAILURE); - } - cmd_rtsp_nat_mode_set = 1; - break; case 'u': safe_free_string(&config.rtsp_user_agent); if (optarg[0] != '\0') { @@ -1763,9 +1699,6 @@ void parse_cmd_line(int argc, char *argv[]) { set_config_file_path(NULL); } - if (validate_rtsp_nat_config() < 0) - exit(EXIT_FAILURE); - apply_bind_side_effects(); /* External M3U will be loaded asynchronously by workers after startup diff --git a/src/configuration.h b/src/configuration.h index c568c047..1e0b4699 100644 --- a/src/configuration.h +++ b/src/configuration.h @@ -26,13 +26,6 @@ typedef enum loglevel { typedef enum { BIND_ADDR_TCP = 0, BIND_ADDR_UNIX } bindaddr_type_t; -typedef enum { - RTSP_NAT_MODE_INVALID = -1, - RTSP_NAT_MODE_AUTO = 0, - RTSP_NAT_MODE_STUN, - RTSP_NAT_MODE_ZTE -} rtsp_nat_mode_t; - /* * Linked list of addresses to bind */ @@ -126,13 +119,12 @@ typedef struct { 1=enabled) */ /* RTSP NAT traversal settings */ - rtsp_nat_mode_t rtsp_nat_mode; /* RTSP NAT traversal mode (AUTO by default) */ - char *rtsp_stun_server; /* STUN server host:port for RTSP NAT traversal - (NULL=disabled) */ - char *http_proxy_user_agent; /* Override User-Agent header for upstream HTTP - proxy requests (NULL=disabled) */ - char *rtsp_user_agent; /* User-Agent header for upstream RTSP requests - (NULL=use default) */ + char *rtsp_stun_server; /* STUN server host:port for RTSP NAT traversal + (NULL=disabled) */ + char *http_proxy_user_agent; /* Override User-Agent header for upstream HTTP + proxy requests (NULL=disabled) */ + char *rtsp_user_agent; /* User-Agent header for upstream RTSP requests + (NULL=use default) */ /* CORS settings */ char *cors_allow_origin; /* CORS Access-Control-Allow-Origin value @@ -241,9 +233,4 @@ int bind_addresses_equal(bindaddr_t *a, bindaddr_t *b); */ int bind_addresses_has_unix(void); -/** - * Resolve AUTO to STUN when an RTSP STUN server is configured. - */ -rtsp_nat_mode_t config_get_effective_rtsp_nat_mode(void); - #endif /* __CONFIGURATION_H__ */ diff --git a/src/rtsp.c b/src/rtsp.c index e42194bb..58a23483 100644 --- a/src/rtsp.c +++ b/src/rtsp.c @@ -12,11 +12,13 @@ #include "stream.h" #include "utils.h" #include "worker.h" +#include #include #include #include #include #include +#include #include #include #include @@ -53,7 +55,6 @@ static void rtsp_close_udp_sockets(rtsp_session_t *session, const char *reason); static char *rtsp_find_header(const char *response, const char *header_name); static void rtsp_parse_transport_header(rtsp_session_t *session, const char *transport); static void rtsp_send_udp_nat_probe(rtsp_session_t *session); -static void rtsp_send_zte_nat_probe(rtsp_session_t *session); static int rtsp_capture_control_endpoints(rtsp_session_t *session); static int rtsp_process_interleaved_buffer(rtsp_session_t *session, connection_t *conn); static int rtsp_handle_redirect(rtsp_session_t *session, const char *location); @@ -302,12 +303,13 @@ void rtsp_session_init(rtsp_session_t *session) { session->metadata_probe = 0; session->peer_closed = 0; session->connect_generation = 0; - session->control_local_addr_len = 0; - session->control_peer_addr_len = 0; + session->control_local_ip4.s_addr = INADDR_ANY; + session->control_peer_ip4.s_addr = INADDR_ANY; session->control_local_ip[0] = '\0'; session->control_local_port = 0; session->control_endpoints_valid = 0; - session->zte_active = 0; + session->zte_nat_active = 0; + session->describe_waiting_for_stun = 0; /* Initialize transport parameters - mode will be negotiated during SETUP */ session->transport_mode = RTSP_TRANSPORT_TCP; /* Default preference */ @@ -752,66 +754,70 @@ static int rtsp_capture_control_endpoints(rtsp_session_t *session) { struct sockaddr_storage peer_addr; socklen_t local_len = sizeof(local_addr); socklen_t peer_len = sizeof(peer_addr); - char local_host[RTSP_SERVER_HOST_SIZE]; - char local_service[RTSP_PORT_STRING_SIZE]; - int local_ip_changed = 0; - int zte_requested = config_get_effective_rtsp_nat_mode() == RTSP_NAT_MODE_ZTE; + char local_host[INET6_ADDRSTRLEN]; + struct in_addr local_ip4; + struct in_addr peer_ip4; + int local_ip_changed; memset(&local_addr, 0, sizeof(local_addr)); memset(&peer_addr, 0, sizeof(peer_addr)); + local_ip4.s_addr = INADDR_ANY; + peer_ip4.s_addr = INADDR_ANY; + if (getsockname(session->socket, (struct sockaddr *)&local_addr, &local_len) < 0) { logger(LOG_ERROR, "RTSP: getsockname() failed: %s", strerror(errno)); - session->control_endpoints_valid = 0; - session->zte_active = 0; - return -1; + goto fail; } if (getpeername(session->socket, (struct sockaddr *)&peer_addr, &peer_len) < 0) { logger(LOG_ERROR, "RTSP: getpeername() failed: %s", strerror(errno)); - session->control_endpoints_valid = 0; - session->zte_active = 0; - return -1; + goto fail; } - if (getnameinfo((struct sockaddr *)&local_addr, local_len, local_host, sizeof(local_host), local_service, - sizeof(local_service), NI_NUMERICHOST | NI_NUMERICSERV) != 0) { + if (sockaddr_format_ip((struct sockaddr *)&local_addr, local_host, sizeof(local_host)) < 0) { logger(LOG_ERROR, "RTSP: Failed to format local control endpoint"); - session->control_endpoints_valid = 0; - session->zte_active = 0; - return -1; + goto fail; } - if (session->control_endpoints_valid && session->control_local_addr.ss_family == AF_INET && - local_addr.ss_family == AF_INET) { - const struct sockaddr_in *old_local = (const struct sockaddr_in *)&session->control_local_addr; - const struct sockaddr_in *new_local = (const struct sockaddr_in *)&local_addr; - local_ip_changed = old_local->sin_addr.s_addr != new_local->sin_addr.s_addr; - } - if (zte_requested && local_ip_changed && session->rtp_socket >= 0) + if (local_addr.ss_family == AF_INET) + local_ip4 = ((const struct sockaddr_in *)&local_addr)->sin_addr; + if (peer_addr.ss_family == AF_INET) + peer_ip4 = ((const struct sockaddr_in *)&peer_addr)->sin_addr; + + /* A reconnect or redirect can land on a different local address; UDP sockets + * pinned to the previous one must be recreated before SETUP. */ + local_ip_changed = session->control_endpoints_valid && session->control_local_ip4.s_addr != local_ip4.s_addr; + if (local_ip_changed && session->rtp_socket >= 0) rtsp_close_udp_sockets(session, "control connection local address changed"); - memcpy(&session->control_local_addr, &local_addr, local_len); - session->control_local_addr_len = local_len; - memcpy(&session->control_peer_addr, &peer_addr, peer_len); - session->control_peer_addr_len = peer_len; + session->control_local_ip4 = local_ip4; + session->control_peer_ip4 = peer_ip4; snprintf(session->control_local_ip, sizeof(session->control_local_ip), "%s", local_host); - session->control_local_port = (uint16_t)atoi(local_service); + session->control_local_port = sockaddr_get_port((struct sockaddr *)&local_addr); session->control_endpoints_valid = 1; - session->zte_active = zte_requested && local_addr.ss_family == AF_INET && peer_addr.ss_family == AF_INET; + /* The ZTE NAT traversal behaviours (x-NAT header, client_address parameter, + * ZXV10STB punch packet) all encode an IPv4 address, so they are limited to + * IPv4 control connections. IPv6 upstreams fall back to plain negotiation. */ + session->zte_nat_active = local_addr.ss_family == AF_INET && peer_addr.ss_family == AF_INET; logger(LOG_INFO, "RTSP: Upstream interface %s, local endpoint %s:%u", session->upstream_ifname && session->upstream_ifname[0] ? session->upstream_ifname : "route-selected", session->control_local_ip, session->control_local_port); #ifdef __FreeBSD__ - if (zte_requested && session->upstream_ifname && session->upstream_ifname[0]) - logger(LOG_WARN, + if (session->upstream_ifname && session->upstream_ifname[0]) + logger(LOG_DEBUG, "RTSP: FreeBSD cannot reliably pin unicast sockets to upstream interface %s; using the actual " "route-selected local endpoint", session->upstream_ifname); #endif - if (zte_requested && !session->zte_active) - logger(LOG_WARN, "RTSP: ZTE NAT traversal only supports IPv4 control connections; using ordinary RTSP negotiation"); + if (!session->zte_nat_active) + logger(LOG_DEBUG, "RTSP: IPv6 control connection, skipping ZTE NAT traversal headers and punch packet"); return 0; + +fail: + session->control_endpoints_valid = 0; + session->zte_nat_active = 0; + return -1; } /** @@ -949,7 +955,7 @@ int rtsp_connect(rtsp_session_t *session) { * This allows STUN to run in parallel with TCP connection establishment * Only do this on initial connect (not on redirect or reconnect for TEARDOWN) * Check: UDP socket not yet created and STUN not already in progress/completed */ - if (!session->metadata_probe && config_get_effective_rtsp_nat_mode() == RTSP_NAT_MODE_STUN && + if (!session->metadata_probe && config.rtsp_stun_server && config.rtsp_stun_server[0] != '\0' && session->rtp_socket < 0 && !session->stun.in_progress && !session->stun.completed) { if (rtsp_setup_udp_sockets(session) == 0) { if (stun_send_request(&session->stun, session->rtp_socket) == 0) { @@ -1344,7 +1350,7 @@ int rtsp_send_keepalive(rtsp_session_t *session) { } /* Send NAT probe packets to maintain UDP hole-punching (UDP only) */ - if (session->transport_mode == RTSP_TRANSPORT_UDP && !session->zte_active) { + if (session->transport_mode == RTSP_TRANSPORT_UDP) { rtsp_send_udp_nat_probe(session); } @@ -1658,8 +1664,127 @@ static int rtsp_try_receive_response(rtsp_session_t *session) { return RTSP_RESPONSE_OK; } +/* + * Resolve the endpoint advertised to the upstream server for NAT traversal. + * + * When STUN discovery succeeded, its public mapping wins: that is the address + * an upstream behind NAT can actually reach. Otherwise the RTSP control + * connection's own local endpoint is used, which is the correct answer when + * rtp2httpd itself holds the operator-facing address (the usual router + * deployment). + * + * Note that STUN discovers the mapping of the *UDP* socket; RTSP has no way to + * discover the TCP control port's mapping, so `control_port` (what the x-NAT + * header names) falls back to the STUN UDP port when STUN is in play. + */ +static void rtsp_nat_endpoint(const rtsp_session_t *session, rtsp_nat_endpoint_t *out) { + uint16_t stun_port = stun_get_mapped_port(&session->stun); + struct in_addr stun_ip = stun_get_mapped_ipv4(&session->stun); + + memset(out, 0, sizeof(*out)); + + if (stun_port > 0) { + out->rtp_port = stun_port; + out->rtcp_port = stun_port + 1; + out->control_port = stun_port; + } else { + out->rtp_port = (uint16_t)session->local_rtp_port; + out->rtcp_port = (uint16_t)session->local_rtcp_port; + out->control_port = session->control_local_port; + } + + if (stun_ip.s_addr != INADDR_ANY) { + out->ip4 = stun_ip; + inet_ntop(AF_INET, &out->ip4, out->ip, sizeof(out->ip)); + } else { + out->ip4 = session->control_local_ip4; + snprintf(out->ip, sizeof(out->ip), "%s", session->control_local_ip); + } +} + +/* + * Append a formatted chunk to a header buffer, keeping *len in sync. Writes + * are clamped to the buffer so callers can chain appends without repeating the + * remaining-space arithmetic. + */ +static void rtsp_append_header(char *buf, size_t size, size_t *len, const char *fmt, ...) + __attribute__((format(printf, 4, 5))); + +static void rtsp_append_header(char *buf, size_t size, size_t *len, const char *fmt, ...) { + va_list args; + int written; + + if (*len + 1 >= size) + return; + + va_start(args, fmt); + written = vsnprintf(buf + *len, size - *len, fmt, args); + va_end(args); + + if (written < 0) + return; + *len += (size_t)written; + if (*len >= size) + *len = size - 1; +} + +/* + * Append the ZTE "x-NAT: :" header naming the endpoint the server + * should treat as ours. No-op on IPv6 control connections. + */ +static void rtsp_append_x_nat_header(const rtsp_session_t *session, char *buf, size_t size, size_t *len) { + rtsp_nat_endpoint_t nat; + + if (!session->zte_nat_active) + return; + rtsp_nat_endpoint(session, &nat); + rtsp_append_header(buf, size, len, "x-NAT: %s:%u\r\n", nat.ip, nat.control_port); +} + +/* + * Build the SETUP "Transport:" header. Every supported alternative is listed + * in preference order (TCP interleaved first, then UDP) so a server that + * cannot serve one can still select another; the punch packet is only sent + * once the server has actually confirmed UDP. The UDP alternatives carry + * client_address/mode=PLAY, matching what ZTE ZXV10 set-top boxes send. + */ +static void rtsp_build_setup_transport(const rtsp_session_t *session, char *buf, size_t size, size_t *len, + int offer_tcp, int offer_udp, int rtp_port, int rtcp_port) { + static const char *const tcp_profiles[] = {"MP2T/RTP/TCP", "MP2T/TCP", "RTP/AVP/TCP"}; + static const char *const udp_profiles[] = {"MP2T/RTP/UDP", "MP2T/UDP", "RTP/AVP"}; + char udp_address[sizeof("client_address=;") + INET6_ADDRSTRLEN] = ""; + const char *udp_mode = ""; + const char *separator = ""; + size_t i; + + if (session->zte_nat_active) { + rtsp_nat_endpoint_t nat; + rtsp_nat_endpoint(session, &nat); + snprintf(udp_address, sizeof(udp_address), "client_address=%s;", nat.ip); + udp_mode = ";mode=PLAY"; + } + + rtsp_append_header(buf, size, len, "Transport: "); + if (offer_tcp) { + for (i = 0; i < ARRAY_SIZE(tcp_profiles); i++) { + rtsp_append_header(buf, size, len, "%s%s;unicast;interleaved=%d-%d", separator, tcp_profiles[i], + session->rtp_channel, session->rtcp_channel); + separator = ","; + } + } + if (offer_udp) { + for (i = 0; i < ARRAY_SIZE(udp_profiles); i++) { + rtsp_append_header(buf, size, len, "%s%s;unicast;%sclient_port=%d-%d%s", separator, udp_profiles[i], udp_address, + rtp_port, rtcp_port, udp_mode); + separator = ","; + } + } + rtsp_append_header(buf, size, len, "\r\n"); +} + int rtsp_state_machine_advance(rtsp_session_t *session) { char extra_headers[RTSP_HEADERS_BUFFER_SIZE]; + size_t headers_len = 0; switch (session->state) { case RTSP_STATE_CONNECTED: @@ -1674,13 +1799,25 @@ int rtsp_state_machine_advance(rtsp_session_t *session) { return 0; case RTSP_STATE_AWAITING_OPTIONS: - /* OPTIONS response received, ready to send DESCRIBE */ - if (session->zte_active) { - snprintf(extra_headers, sizeof(extra_headers), "Accept: application/sdp\r\nx-NAT: %s:%u\r\n", - session->control_local_ip, session->control_local_port); - } else { - snprintf(extra_headers, sizeof(extra_headers), "Accept: application/sdp\r\n"); + /* OPTIONS response received, ready to send DESCRIBE. + * DESCRIBE already carries the x-NAT header, so a STUN discovery started + * back at connect() has to finish first - otherwise x-NAT would name the + * private endpoint while SETUP later advertises the public mapping. + * STUN is bounded by its own retry budget, and rtsp_session_tick() pauses + * the handshake timeout while it runs. */ + if (session->stun.in_progress) { + stun_check_timeout(&session->stun, session->rtp_socket); + if (session->stun.in_progress) { + session->describe_waiting_for_stun = 1; + logger(LOG_DEBUG, "RTSP: Waiting for STUN response before sending DESCRIBE"); + return 0; /* Stay in AWAITING_OPTIONS, will be called again */ + } } + session->describe_waiting_for_stun = 0; + + extra_headers[0] = '\0'; + rtsp_append_header(extra_headers, sizeof(extra_headers), &headers_len, "Accept: application/sdp\r\n"); + rtsp_append_x_nat_header(session, extra_headers, sizeof(extra_headers), &headers_len); if (rtsp_prepare_request(session, RTSP_METHOD_DESCRIBE, NULL, extra_headers) < 0) { logger(LOG_ERROR, "RTSP: Failed to prepare DESCRIBE request"); return -1; @@ -1692,7 +1829,7 @@ int rtsp_state_machine_advance(rtsp_session_t *session) { case RTSP_STATE_DESCRIBED: { /* Ready to send SETUP - first setup UDP sockets if needed */ int udp_setup_ok = 0; - int advertised_rtp_port, advertised_rtcp_port; + int advertised_rtp_port = 0, advertised_rtcp_port = 0; /* Check if UDP sockets were already created for STUN */ if (session->rtp_socket >= 0) { @@ -1701,69 +1838,25 @@ int rtsp_state_machine_advance(rtsp_session_t *session) { udp_setup_ok = 1; } - if (!udp_setup_ok) { - logger(LOG_DEBUG, "RTSP: Failed to setup UDP sockets, will only offer TCP transport"); - snprintf(extra_headers, sizeof(extra_headers), - "Transport: MP2T/RTP/TCP;unicast;interleaved=%d-%d," - "MP2T/TCP;unicast;interleaved=%d-%d," - "RTP/AVP/TCP;unicast;interleaved=%d-%d\r\n", - session->rtp_channel, session->rtcp_channel, session->rtp_channel, session->rtcp_channel, - session->rtp_channel, session->rtcp_channel); - } else if (session->zte_active) { - advertised_rtp_port = session->local_rtp_port; - advertised_rtcp_port = session->local_rtcp_port; - snprintf(extra_headers, sizeof(extra_headers), - "Transport: MP2T/RTP/UDP;unicast;client_address=%s;client_port=%d-%d;mode=PLAY\r\n" - "x-NAT: %s:%u\r\n", - session->control_local_ip, advertised_rtp_port, advertised_rtcp_port, session->control_local_ip, - session->control_local_port); - } else { - /* Check STUN status and determine which port to advertise */ - if (session->stun.in_progress) { - /* STUN still in progress - check for timeout/retry */ - stun_check_timeout(&session->stun, session->rtp_socket); - - /* If STUN is still in progress after timeout check, wait for it */ - if (session->stun.in_progress) { - logger(LOG_DEBUG, "RTSP: Waiting for STUN response before sending SETUP"); - return 0; /* Stay in DESCRIBED state, will be called again */ - } - } - - /* Use STUN mapped port if available, otherwise use local port */ - advertised_rtp_port = stun_get_mapped_port(&session->stun); - if (advertised_rtp_port > 0) { - advertised_rtcp_port = advertised_rtp_port + 1; + /* STUN, if any, already settled before DESCRIBE was sent */ + if (udp_setup_ok) { + rtsp_nat_endpoint_t nat; + rtsp_nat_endpoint(session, &nat); + advertised_rtp_port = nat.rtp_port; + advertised_rtcp_port = nat.rtcp_port; + if (stun_get_mapped_port(&session->stun) > 0) logger(LOG_DEBUG, "RTSP: Using STUN mapped ports %d-%d for SETUP Transport", advertised_rtp_port, advertised_rtcp_port); - } else { - advertised_rtp_port = session->local_rtp_port; - advertised_rtcp_port = session->local_rtcp_port; - if (config_get_effective_rtsp_nat_mode() == RTSP_NAT_MODE_STUN) { - logger(LOG_DEBUG, "RTSP: STUN timed out, using local ports %d-%d", advertised_rtp_port, advertised_rtcp_port); - } - } - - if (RTSP_DISABLE_TCP_TRANSPORT) { - snprintf(extra_headers, sizeof(extra_headers), - "Transport: MP2T/RTP/UDP;unicast;client_port=%d-%d," - "MP2T/UDP;unicast;client_port=%d-%d," - "RTP/AVP;unicast;client_port=%d-%d\r\n", - advertised_rtp_port, advertised_rtcp_port, advertised_rtp_port, advertised_rtcp_port, - advertised_rtp_port, advertised_rtcp_port); - } else { - snprintf(extra_headers, sizeof(extra_headers), - "Transport: MP2T/RTP/TCP;unicast;interleaved=%d-%d," - "MP2T/TCP;unicast;interleaved=%d-%d," - "RTP/AVP/TCP;unicast;interleaved=%d-%d," - "MP2T/RTP/UDP;unicast;client_port=%d-%d," - "MP2T/UDP;unicast;client_port=%d-%d," - "RTP/AVP;unicast;client_port=%d-%d\r\n", - session->rtp_channel, session->rtcp_channel, session->rtp_channel, session->rtcp_channel, - session->rtp_channel, session->rtcp_channel, advertised_rtp_port, advertised_rtcp_port, - advertised_rtp_port, advertised_rtcp_port, advertised_rtp_port, advertised_rtcp_port); - } + } else { + logger(LOG_DEBUG, "RTSP: Failed to setup UDP sockets, will only offer TCP transport"); } + + extra_headers[0] = '\0'; + rtsp_build_setup_transport(session, extra_headers, sizeof(extra_headers), &headers_len, + !udp_setup_ok || !RTSP_DISABLE_TCP_TRANSPORT, udp_setup_ok, advertised_rtp_port, + advertised_rtcp_port); + rtsp_append_x_nat_header(session, extra_headers, sizeof(extra_headers), &headers_len); + if (rtsp_prepare_request(session, RTSP_METHOD_SETUP, session->setup_url[0] ? session->setup_url : NULL, extra_headers) < 0) { logger(LOG_ERROR, "RTSP: Failed to prepare SETUP request"); @@ -1835,6 +1928,13 @@ int rtsp_session_tick(rtsp_session_t *session, int64_t now) { switch (session->state) { case RTSP_STATE_CONNECTING: case RTSP_STATE_AWAITING_OPTIONS: + /* DESCRIBE is deliberately held back until STUN settles; STUN has its own + * bounded retry budget, so don't also count it against the handshake. */ + if (session->describe_waiting_for_stun) + timeout_sec = 0; + else + timeout_sec = RTSP_HANDSHAKE_TIMEOUT_SEC; + break; case RTSP_STATE_AWAITING_DESCRIBE: case RTSP_STATE_AWAITING_SETUP: case RTSP_STATE_AWAITING_PLAY: @@ -1869,12 +1969,12 @@ int rtsp_session_tick(rtsp_session_t *session, int64_t now) { } /* Check STUN timeout if waiting for STUN response */ - if (session->stun.in_progress && session->state == RTSP_STATE_DESCRIBED) { + if (session->stun.in_progress && session->describe_waiting_for_stun) { if (stun_check_timeout(&session->stun, session->rtp_socket) > 0) { - /* STUN finally timed out, advance state machine to continue with local - * port */ + /* STUN finally timed out, advance state machine to continue with the + * local endpoint */ if (rtsp_state_machine_advance(session) == 0) { - /* Re-arm POLLER_OUT so the pending SETUP request gets sent */ + /* Re-arm POLLER_OUT so the pending DESCRIBE request gets sent */ if (session->epoll_fd >= 0) { poller_mod(session->epoll_fd, session->socket, POLLER_IN | POLLER_OUT | POLLER_HUP | POLLER_ERR | POLLER_RDHUP); @@ -2102,9 +2202,9 @@ int rtsp_handle_udp_rtp_data(rtsp_session_t *session, connection_t *conn) { if (stun_parse_response(&session->stun, stun_buf, stun_len) == 0) { logger(LOG_INFO, "RTSP: STUN discovery completed, mapped RTP port: %d", stun_get_mapped_port(&session->stun)); /* If state machine was waiting for STUN, advance it now */ - if (session->state == RTSP_STATE_DESCRIBED) { + if (session->describe_waiting_for_stun) { if (rtsp_state_machine_advance(session) == 0) { - /* Re-arm POLLER_OUT so the pending SETUP request gets sent */ + /* Re-arm POLLER_OUT so the pending DESCRIBE request gets sent */ if (session->epoll_fd >= 0) { poller_mod(session->epoll_fd, session->socket, POLLER_IN | POLLER_OUT | POLLER_HUP | POLLER_ERR | POLLER_RDHUP); @@ -2631,12 +2731,10 @@ static int rtsp_setup_udp_sockets(rtsp_session_t *session) { } else { struct sockaddr_in *sin = (struct sockaddr_in *)&local_addr; sin->sin_family = AF_INET; - if (session->zte_active) { - const struct sockaddr_in *control_local = (const struct sockaddr_in *)&session->control_local_addr; - sin->sin_addr = control_local->sin_addr; - } else { - sin->sin_addr.s_addr = INADDR_ANY; - } + /* ZTE mode pins the media sockets to the same local address the RTSP + * control connection uses, since that address is what the x-NAT header and + * the punch packet advertise to the server. */ + sin->sin_addr.s_addr = session->zte_nat_active ? session->control_local_ip4.s_addr : INADDR_ANY; local_addr_len = sizeof(struct sockaddr_in); } @@ -2755,13 +2853,9 @@ static int rtsp_setup_udp_sockets(rtsp_session_t *session) { logger(LOG_DEBUG, "RTSP: RTCP socket registered with poller"); } - if (session->zte_active) { - logger(LOG_DEBUG, "RTSP: UDP sockets bound to %s:%d (RTP) and %s:%d (RTCP)", session->control_local_ip, - session->local_rtp_port, session->control_local_ip, session->local_rtcp_port); - } else { - logger(LOG_DEBUG, "RTSP: UDP sockets bound to ports %d (RTP) and %d (RTCP)", session->local_rtp_port, - session->local_rtcp_port); - } + logger(LOG_DEBUG, "RTSP: UDP sockets bound to %s ports %d (RTP) and %d (RTCP)", + session->zte_nat_active ? session->control_local_ip : "any address", session->local_rtp_port, + session->local_rtcp_port); return 0; } @@ -3134,114 +3228,127 @@ static void rtsp_parse_describe_sdp(rtsp_session_t *session, const char *header_ } } +/* + * Punch the media path so the operator network starts forwarding. + * + * On the RTP socket this sends the ZTE ZXV10 set-top box authentication + * datagram, which those deployments require before media flows. Wire format + * (multi-byte fields are big-endian): + * + * 0..7 "ZXV10STB" magic + * 8..11 0x7fffffff + * 12..15 client IPv4 address + * 16..17 client RTP port + * 18..19 client RTSP control port + * 20..83 zero padding + * + * The protocol details are derived from https://github.com/plsy1/rtsproxy. + * Addresses and ports come from rtsp_nat_endpoint(), so behind NAT they carry + * the STUN-discovered public mapping rather than the private endpoint. + * + * On IPv6 control connections the packet cannot be built (its address field is + * IPv4) and a minimal RTP datagram is sent instead. RTCP is punched with a + * minimal Receiver Report in both cases. Everything is sent three times and + * repeated on every keepalive, since UDP gives no delivery guarantee and NAT + * bindings expire. + */ static void rtsp_send_udp_nat_probe(rtsp_session_t *session) { - char port_str[RTSP_PORT_STRING_SIZE]; - struct addrinfo hints; - struct addrinfo *result = NULL; - struct addrinfo *rp; - uint8_t rtp_packet[12]; + struct sockaddr_storage destination; + socklen_t destination_len = 0; + rtsp_nat_endpoint_t nat; + uint8_t rtp_packet[84]; + size_t rtp_packet_len; uint8_t rtcp_packet[8]; + uint16_t network_port; + char destination_ip[INET6_ADDRSTRLEN] = ""; - if (!session || session->server_source_addr[0] == '\0') { + if (!session || session->server_rtp_port <= 0 || session->server_rtp_port > 65535) return; + + memset(&destination, 0, sizeof(destination)); + + /* Prefer the media source the server named in Transport; fall back to the + * RTSP control peer, which is where ZTE servers expect the punch anyway. */ + if (session->server_source_addr[0] != '\0') { + struct addrinfo hints; + struct addrinfo *result = NULL; + struct addrinfo *rp; + char port_str[RTSP_PORT_STRING_SIZE]; + + memset(&hints, 0, sizeof(hints)); + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_DGRAM; + hints.ai_protocol = IPPROTO_UDP; + snprintf(port_str, sizeof(port_str), "%d", session->server_rtp_port); + + if (getaddrinfo(session->server_source_addr, port_str, &hints, &result) == 0) { + for (rp = result; rp != NULL; rp = rp->ai_next) { + if (rp->ai_family == session->upstream_family) + break; + } + if (!rp) + rp = result; /* Fallback: try the first resolved address */ + memcpy(&destination, rp->ai_addr, rp->ai_addrlen); + destination_len = rp->ai_addrlen; + freeaddrinfo(result); + } + } + if (destination_len == 0 && session->control_endpoints_valid && session->zte_nat_active) { + struct sockaddr_in *sin = (struct sockaddr_in *)&destination; + sin->sin_family = AF_INET; + sin->sin_addr = session->control_peer_ip4; + destination_len = sizeof(struct sockaddr_in); } + if (destination_len == 0) + return; + + sockaddr_format_ip((struct sockaddr *)&destination, destination_ip, sizeof(destination_ip)); + rtsp_nat_endpoint(session, &nat); - /* Build minimal RTP packet - 12 bytes */ memset(rtp_packet, 0, sizeof(rtp_packet)); - rtp_packet[0] = 0x80; /* V=2, P=0, X=0, CC=0 */ - rtp_packet[1] = 0x00; /* M=0, PT=0 */ + if (session->zte_nat_active) { + rtp_packet_len = 84; + memcpy(rtp_packet, "ZXV10STB", 8); + rtp_packet[8] = 0x7f; + rtp_packet[9] = 0xff; + rtp_packet[10] = 0xff; + rtp_packet[11] = 0xff; + memcpy(rtp_packet + 12, &nat.ip4.s_addr, sizeof(nat.ip4.s_addr)); + network_port = htons(nat.rtp_port); + memcpy(rtp_packet + 16, &network_port, sizeof(network_port)); + network_port = htons(nat.control_port); + memcpy(rtp_packet + 18, &network_port, sizeof(network_port)); + } else { + /* Minimal RTP packet - 12 bytes */ + rtp_packet_len = 12; + rtp_packet[0] = 0x80; /* V=2, P=0, X=0, CC=0 */ + rtp_packet[1] = 0x00; /* M=0, PT=0 */ + } - /* Build minimal RTCP RR (Receiver Report) - 8 bytes */ + /* Minimal RTCP RR (Receiver Report) - 8 bytes */ memset(rtcp_packet, 0, sizeof(rtcp_packet)); rtcp_packet[0] = 0x80; /* V=2, P=0, RC=0 */ rtcp_packet[1] = 201; /* PT=201 (Receiver Report) */ rtcp_packet[2] = 0x00; /* length in words - 1 (high byte) */ rtcp_packet[3] = 0x01; /* length in words - 1 (low byte) = 1 */ - memset(&hints, 0, sizeof(hints)); - hints.ai_family = AF_UNSPEC; - hints.ai_socktype = SOCK_DGRAM; - hints.ai_protocol = IPPROTO_UDP; - - /* Resolve server address once for both RTP and RTCP */ - snprintf(port_str, sizeof(port_str), "%d", session->server_rtp_port); - if (getaddrinfo(session->server_source_addr, port_str, &hints, &result) != 0) { - return; - } - - /* Pick the first address matching the UDP socket address family */ - for (rp = result; rp != NULL; rp = rp->ai_next) { - if (rp->ai_family == session->upstream_family) { - break; - } - } - if (!rp) { - rp = result; /* Fallback: try the first resolved address */ - } - - /* Send 3 NAT probe packets for both RTP and RTCP */ for (int attempt = 0; attempt < 3; attempt++) { - /* Send RTP probe */ - if (session->server_rtp_port > 0 && session->rtp_socket >= 0) { - sockaddr_set_port(rp->ai_addr, (uint16_t)session->server_rtp_port); - sendto(session->rtp_socket, rtp_packet, sizeof(rtp_packet), 0, rp->ai_addr, rp->ai_addrlen); + if (session->rtp_socket >= 0) { + sockaddr_set_port((struct sockaddr *)&destination, (uint16_t)session->server_rtp_port); + sendto(session->rtp_socket, rtp_packet, rtp_packet_len, 0, (struct sockaddr *)&destination, destination_len); } - - /* Send RTCP probe - update port in sockaddr */ if (session->server_rtcp_port > 0 && session->rtcp_socket >= 0) { - sockaddr_set_port(rp->ai_addr, (uint16_t)session->server_rtcp_port); - sendto(session->rtcp_socket, rtcp_packet, sizeof(rtcp_packet), 0, rp->ai_addr, rp->ai_addrlen); + sockaddr_set_port((struct sockaddr *)&destination, (uint16_t)session->server_rtcp_port); + sendto(session->rtcp_socket, rtcp_packet, sizeof(rtcp_packet), 0, (struct sockaddr *)&destination, + destination_len); } } - freeaddrinfo(result); - logger(LOG_DEBUG, "RTSP: Sent NAT probe packets to %s:%d/%d", session->server_source_addr, session->server_rtp_port, + logger(LOG_DEBUG, "RTSP: Sent 3x %zu-byte %s NAT probe to %s:%d/%d", rtp_packet_len, + session->zte_nat_active ? "ZXV10STB" : "RTP", destination_ip, session->server_rtp_port, session->server_rtcp_port); } -static void rtsp_send_zte_nat_probe(rtsp_session_t *session) { - uint8_t packet[84]; - struct sockaddr_storage destination; - const struct sockaddr_in *local_addr; - uint16_t network_port; - ssize_t sent; - - if (!session || !session->zte_active || !session->control_endpoints_valid || session->rtp_socket < 0 || - session->control_peer_addr.ss_family != AF_INET) - return; - if (session->server_rtp_port <= 0 || session->server_rtp_port > 65535) { - logger(LOG_WARN, "RTSP: Cannot send ZTE NAT probe without a valid server RTP port"); - return; - } - - memset(packet, 0, sizeof(packet)); - memcpy(packet, "ZXV10STB", 8); - packet[8] = 0x7f; - packet[9] = 0xff; - packet[10] = 0xff; - packet[11] = 0xff; - - local_addr = (const struct sockaddr_in *)&session->control_local_addr; - memcpy(packet + 12, &local_addr->sin_addr.s_addr, sizeof(local_addr->sin_addr.s_addr)); - network_port = htons((uint16_t)session->local_rtp_port); - memcpy(packet + 16, &network_port, sizeof(network_port)); - network_port = htons(session->control_local_port); - memcpy(packet + 18, &network_port, sizeof(network_port)); - - memcpy(&destination, &session->control_peer_addr, session->control_peer_addr_len); - sockaddr_set_port((struct sockaddr *)&destination, (uint16_t)session->server_rtp_port); - sent = sendto(session->rtp_socket, packet, sizeof(packet), 0, (struct sockaddr *)&destination, - session->control_peer_addr_len); - if (sent != (ssize_t)sizeof(packet)) { - logger(LOG_WARN, "RTSP: Failed to send ZTE NAT probe to control peer port %d: %s", session->server_rtp_port, - sent < 0 ? strerror(errno) : "short send"); - return; - } - - logger(LOG_DEBUG, "RTSP: Sent 84-byte ZTE NAT probe from %s:%d to control peer port %d", session->control_local_ip, - session->local_rtp_port, session->server_rtp_port); -} - static void rtsp_parse_transport_header(rtsp_session_t *session, const char *transport) { char *server_port_param; char *interleaved_param; @@ -3346,12 +3453,8 @@ static void rtsp_parse_transport_header(rtsp_session_t *session, const char *tra } } - if (session->zte_active) { - rtsp_send_zte_nat_probe(session); - } else { - /* Send ordinary NAT probe packets if server provided source address and ports */ - rtsp_send_udp_nat_probe(session); - } + /* Punch the media path now that the server has confirmed UDP */ + rtsp_send_udp_nat_probe(session); } } diff --git a/src/rtsp.h b/src/rtsp.h index 3af5ccaf..cdb1f33b 100644 --- a/src/rtsp.h +++ b/src/rtsp.h @@ -1,8 +1,8 @@ #ifndef __RTSP_H__ #define __RTSP_H__ +#include #include -#include #include #include "stun.h" @@ -10,6 +10,18 @@ /* Forward declaration */ struct addrinfo; +/* + * Endpoint advertised upstream for NAT traversal: either the STUN-discovered + * public mapping or the RTSP control connection's own local endpoint. + */ +typedef struct { + struct in_addr ip4; + char ip[INET6_ADDRSTRLEN]; + uint16_t control_port; /* port named by the x-NAT header */ + uint16_t rtp_port; /* port advertised as client_port / in the punch packet */ + uint16_t rtcp_port; +} rtsp_nat_endpoint_t; + #define RTSP_DISABLE_TCP_TRANSPORT 0 /* To debug UDP transport, set to 1 */ /* Timeout constants for RTSP state machine */ @@ -153,15 +165,27 @@ typedef struct { * fd numbers cannot be used for this: close()+socket() often reuses them. */ unsigned connect_generation; - /* Actual control connection endpoints selected by the kernel. */ - struct sockaddr_storage control_local_addr; - socklen_t control_local_addr_len; - struct sockaddr_storage control_peer_addr; - socklen_t control_peer_addr_len; - char control_local_ip[RTSP_SERVER_HOST_SIZE]; + /* Control connection endpoints as actually selected by the kernel. The ZTE + * x-NAT header and punch packet are IPv4-only, so only the IPv4 addresses are + * retained next to the printable form of the local address (which is also + * filled for IPv6 control connections, for logging). */ + struct in_addr control_local_ip4; + struct in_addr control_peer_ip4; + char control_local_ip[INET6_ADDRSTRLEN]; uint16_t control_local_port; int control_endpoints_valid; - int zte_active; + /* ZTE NAT traversal is in effect for this session: send the x-NAT header and + * the client_address parameter, pin the media sockets to the control + * connection's local address, and punch with the ZXV10STB packet. Requires + * IPv4 on both ends of the control connection. */ + int zte_nat_active; + + /* Set while the OPTIONS response has been processed but DESCRIBE is held + * back waiting for STUN. The state stays AWAITING_OPTIONS throughout, so + * this flag is what distinguishes "parked, safe to resume" from "OPTIONS + * still in flight" - resuming in the latter would pipeline DESCRIBE behind + * an unanswered OPTIONS. */ + int describe_waiting_for_stun; /* Authentication state */ char username[RTSP_CREDENTIAL_SIZE]; /* RTSP username for authentication */ diff --git a/src/stun.c b/src/stun.c index e26581c8..e338cb09 100644 --- a/src/stun.c +++ b/src/stun.c @@ -211,17 +211,18 @@ int stun_parse_response(stun_state_t *state, const uint8_t *data, size_t len) { ((uint32_t)data[val_off + 6] << 8) | data[val_off + 7]; uint32_t addr = xaddr ^ STUN_MAGIC_COOKIE; - state->mapped_rtp_port = port; - state->mapped_rtcp_port = port + 1; - state->in_progress = 0; - state->completed = 1; - /* Log the mapped address */ struct in_addr ina; ina.s_addr = htonl(addr); char ip_str[INET_ADDRSTRLEN]; inet_ntop(AF_INET, &ina, ip_str, sizeof(ip_str)); + state->mapped_rtp_port = port; + state->mapped_rtcp_port = port + 1; + state->mapped_ip4 = ina; + state->in_progress = 0; + state->completed = 1; + logger(LOG_INFO, "STUN: Discovered mapped address %s:%d", ip_str, port); return 0; } @@ -260,6 +261,10 @@ int stun_parse_response(stun_state_t *state, const uint8_t *data, size_t len) { state->mapped_rtp_port = port; state->mapped_rtcp_port = port + 1; + if (family == STUN_ADDR_FAMILY_IPV4) { + /* MAPPED-ADDRESS carries the address unobfuscated */ + memcpy(&state->mapped_ip4.s_addr, data + val_off + 4, 4); + } state->in_progress = 0; state->completed = 1; @@ -318,6 +323,15 @@ uint16_t stun_get_mapped_port(const stun_state_t *state) { return state->mapped_rtp_port; } +struct in_addr stun_get_mapped_ipv4(const stun_state_t *state) { + struct in_addr none; + + if (state) + return state->mapped_ip4; + none.s_addr = INADDR_ANY; + return none; +} + int stun_is_stun_packet(const uint8_t *data, size_t len) { if (!data || len < 20) { return 0; diff --git a/src/stun.h b/src/stun.h index caea756a..09a96821 100644 --- a/src/stun.h +++ b/src/stun.h @@ -6,6 +6,7 @@ #ifndef __STUN_H__ #define __STUN_H__ +#include #include #include @@ -26,6 +27,7 @@ typedef struct { int retry_count; /* Number of retries attempted */ uint16_t mapped_rtp_port; /* Discovered mapped RTP port (0=none) */ uint16_t mapped_rtcp_port; /* Discovered mapped RTCP port (0=none) */ + struct in_addr mapped_ip4; /* Discovered mapped IPv4 address (INADDR_ANY=none) */ unsigned char transaction_id[STUN_TRANSACTION_ID_SIZE]; /* Transaction ID */ } stun_state_t; @@ -66,6 +68,13 @@ int stun_check_timeout(stun_state_t *state, int socket_fd); */ uint16_t stun_get_mapped_port(const stun_state_t *state); +/** + * Get the discovered mapped IPv4 address + * @param state STUN state structure + * @return Mapped IPv4 address, or INADDR_ANY if not discovered (or IPv6) + */ +struct in_addr stun_get_mapped_ipv4(const stun_state_t *state); + /** * Check if a UDP packet looks like a STUN response * STUN messages have first two bits as 00 diff --git a/src/utils.c b/src/utils.c index d59c2f09..ec7702ce 100644 --- a/src/utils.c +++ b/src/utils.c @@ -515,6 +515,31 @@ void sockaddr_set_port(struct sockaddr *sa, uint16_t port) { } } +uint16_t sockaddr_get_port(const struct sockaddr *sa) { + if (!sa) + return 0; + if (sa->sa_family == AF_INET) + return ntohs(((const struct sockaddr_in *)(uintptr_t)sa)->sin_port); + if (sa->sa_family == AF_INET6) + return ntohs(((const struct sockaddr_in6 *)(uintptr_t)sa)->sin6_port); + return 0; +} + +int sockaddr_format_ip(const struct sockaddr *sa, char *buf, size_t size) { + const void *addr; + + if (!sa || !buf || size == 0) + return -1; + if (sa->sa_family == AF_INET) + addr = &((const struct sockaddr_in *)(uintptr_t)sa)->sin_addr; + else if (sa->sa_family == AF_INET6) + addr = &((const struct sockaddr_in6 *)(uintptr_t)sa)->sin6_addr; + else + return -1; + + return inet_ntop(sa->sa_family, addr, buf, (socklen_t)size) ? 0 : -1; +} + char *build_proxy_base_url(const char *host_header, const char *x_forwarded_host, const char *x_forwarded_proto) { const char *host = NULL; const char *proto = "http"; diff --git a/src/utils.h b/src/utils.h index 74335aa3..32bfbd3a 100644 --- a/src/utils.h +++ b/src/utils.h @@ -216,6 +216,24 @@ int parse_host_port(const char *input, char *host, size_t host_size, int *port); */ void sockaddr_set_port(struct sockaddr *sa, uint16_t port); +/** + * Get the port of a sockaddr (AF_INET or AF_INET6). + * + * @param sa Socket address + * @return Port number in host byte order, 0 for an unsupported family + */ +uint16_t sockaddr_get_port(const struct sockaddr *sa); + +/** + * Format the address of a sockaddr in numeric form (AF_INET or AF_INET6). + * + * @param sa Socket address + * @param buf Output buffer, should be at least INET6_ADDRSTRLEN bytes + * @param size Output buffer size + * @return 0 on success, -1 on unsupported family or insufficient space + */ +int sockaddr_format_ip(const struct sockaddr *sa, char *buf, size_t size); + /* Array size calculation macro */ #define ARRAY_SIZE(arr) (sizeof(arr) / sizeof((arr)[0]))