feat(rtsp): always apply ZTE NAT traversal - #701
Conversation
Documentation previewThe documentation preview has been deployed for this pull request. |
There was a problem hiding this comment.
Pull request overview
This PR adds an explicit RTSP NAT traversal mode selection (none|stun|zte) and implements ZTE ZXV10STB NAT punching by deriving the actual local/peer endpoints from the established RTSP TCP control socket, keeping UDP socket binding aligned on multi-interface systems. It also updates OpenWrt/iKuai integration, configuration samples, and adds E2E coverage for the new mode and compatibility behavior.
Changes:
- Add
rtsp-nat-mode(config + CLI) with legacy STUN inference viaconfig_get_effective_rtsp_nat_mode(). - Implement ZTE NAT punch flow and control-endpoint capture; bind UDP sockets to the RTSP TCP-selected local IPv4 when in ZTE mode.
- Add OpenWrt/iKuai wiring, docs updates, and new E2E tests + mock server behavior for ZTE.
Reviewed changes
Copilot reviewed 17 out of 17 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| src/rtsp.h | Extend RTSP session state with captured control socket endpoints and ZTE mode flags. |
| src/rtsp.c | Implement endpoint capture, ZTE header + probe behavior, and ZTE-aligned UDP binding. |
| src/configuration.h | Introduce rtsp_nat_mode_t and expose effective NAT-mode resolver API. |
| src/configuration.c | Parse/validate rtsp-nat-mode, add CLI flag, and implement legacy inference logic. |
| rtp2httpd.conf | Document new NAT mode option in sample config. |
| openwrt-support/rtp2httpd/files/rtp2httpd.init | Pass --rtsp-nat-mode from UCI config to daemon. |
| openwrt-support/rtp2httpd/files/rtp2httpd.conf | Add UCI option example for rtsp_nat_mode. |
| openwrt-support/luci-app-rtp2httpd/po/zh_Hans/rtp2httpd.po | Add localized strings for NAT mode UI. |
| openwrt-support/luci-app-rtp2httpd/po/templates/rtp2httpd.pot | Add template strings for NAT mode UI. |
| openwrt-support/luci-app-rtp2httpd/htdocs/luci-static/resources/view/rtp2httpd.js | Add LuCI form option for selecting NAT mode. |
| ikuai-support/rtp2httpd/scripts/start.sh | Add env-driven --rtsp-nat-mode CLI wiring. |
| ikuai-support/rtp2httpd/app/option.json | Add iKuai UI/config option for NAT mode. |
| e2e/test_rtsp_zte_nat.py | New end-to-end tests covering ZTE punch bytes, ordering, interface alignment, redirects, IPv6 fallback, and reload behavior. |
| e2e/helpers/mock_rtsp.py | Add ZTE-capable mock RTSP server and request event instrumentation. |
| e2e/helpers/init.py | Export the new MockRTSPServerZTE helper. |
| docs/reference/configuration.md | Document --rtsp-nat-mode and add sample config entries (ZH). |
| docs/en/reference/configuration.md | Document --rtsp-nat-mode and add sample config entries (EN). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b5c1968f69
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
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.
## Summary Apply ZTE `ZXV10STB` NAT traversal to every IPv4 upstream RTSP connection, with no configuration switch. For IPv4 control connections rtp2httpd now: - Sends `x-NAT: <address>:<port>` on DESCRIBE and SETUP. - Appends `client_address=<address>` and `mode=PLAY` to the UDP transport candidates in SETUP. - Punches the media path with the 84-byte `ZXV10STB` packet once the server confirms UDP, keeping the existing 3x burst and per-keepalive retransmission. The advertised address and port are resolved in one place (`rtsp_nat_endpoint()`): the STUN-discovered public mapping when `rtsp-stun-server` is configured and discovery succeeds, otherwise the RTSP control connection's own local endpoint (which follows `r2h-ifname` / `upstream-interface-rtsp` / `upstream-interface` / routing table priority). RTP/RTCP sockets are pinned to that local address so the TCP and UDP source addresses stay aligned on multi-interface systems. Because DESCRIBE already carries `x-NAT`, STUN discovery now has to settle before DESCRIBE is sent rather than before SETUP. ## Why Issue stackia#700 reports a Shandong Unicom line behind what looks like symmetric NAT, where no STUN server produces a usable mapping but [rtsproxy](https://github.com/plsy1/rtsproxy)'s ZTE mode connects. The `ZXV10STB` packet is an authentication/punch datagram those operator networks expect on the RTP path; the wire format here is derived from that project and documented inline. This started out as an opt-in `rtsp-nat-mode = auto|stun|zte` switch. It was dropped in favour of always-on because the behaviour is additive rather than exclusive: the punch packet replaces only the payload of a probe that was already being sent, STUN still runs and now feeds the advertised endpoint instead of being bypassed, and the SETUP transport candidate list is unchanged. That leaves nothing a user would need to choose between. Refs stackia#700. ## Impact - No new configuration, and no user-facing documentation: the feature is transparent. - SETUP always offers the full candidate list (TCP interleaved and UDP); the server still picks the transport, and no punch packet is sent when it selects TCP interleaved. - IPv6 upstream connections are unaffected — the punch packet's address field is IPv4-only, so those fall back to plain RTSP negotiation with the previous minimal-RTP probe. - Existing STUN deployments keep working: the discovered mapping is now also what `x-NAT` and `client_address` advertise, instead of only `client_port`. - The punch destination is the `source=` address when the server provides one, falling back to the RTSP control peer. Previously no probe was sent at all when `source=` was absent. ## Notable implementation details - **DESCRIBE must not be pipelined.** `AWAITING_OPTIONS` means "OPTIONS sent, response not yet received", so resuming the state machine from the STUN handler on that state alone wrote OPTIONS and DESCRIBE into a single segment (CSeq 1 and 2 back to back). An explicit `describe_waiting_for_stun` flag now marks the parked state. - **STUN's retry budget vs. the handshake timeout.** Both are ~3s, so the handshake timeout is suspended while parked; STUN's own bounded retries still terminate the wait. - `stun_state_t` now retains the mapped IPv4 address, which was previously parsed only to be logged. - Drive-by: `config_reload()` leaked the partially parsed bind address list on its failure path. - Drive-by: the mock RTSP server folded pipelined requests into a single parsed request, which is what masked the DESCRIBE pipelining above. It now buffers the remainder so an unexpectedly pipelined request is visible to tests. ## Validation - Release CMake build, no warnings - `pnpm run lint` (biome, ruff, clang-format) - ZTE E2E suite: 8 passed - STUN E2E suite: 5 passed - Full E2E suite: 563 passed, 8 skipped Note: `test_rtsp_misc.py::TestRTSPTeardownTimeout::test_teardown_timeout_cleanup` failed once across several full-suite runs and did not reproduce afterwards. It does not touch the NAT/STUN path; likely pre-existing timing sensitivity, but not confirmed.
…S output (#704) ## Summary A non-RTP datagram arriving on a media socket was forwarded to the client verbatim. The media sockets are unconnected, so anything can land in the same `recv()` as the media — in practice a ZTE `ZXV10STB` punch reply, which rtp2httpd solicits on every 30s keepalive since #701. The 84-byte packet shifts the rest of the HTTP body off the 188-byte TS grid. ffmpeg-based players (aptv, VLC) resync on the next sync byte and show nothing; the embedded web player (mpegts.js) only probes for TS alignment at the *start* of a stream, so it stalls on "loading" and re-breaks every 30 seconds. Only bare MPEG-TS is legitimate on the non-RTP path (servers negotiating plain MP2T, raw TS multicast), so the pass-through is now gated on the existing `stream_payload_is_mpegts()` check and everything else is dropped with a debug log. ## Why the reporter saw what they saw Feedback on #701: the web player stalls ~30s in while other clients are fine, and routing through [rtsproxy](https://github.com/plsy1/rtsproxy) fixes it. All three observations fall out of this bug: - **30s** is exactly `RTSP_KEEPALIVE_INTERVAL_MS`, when the punch is re-sent. - **Only the web player** breaks, because only it needs the byte stream to stay aligned mid-stream. - **rtsproxy is clean** for two independent reasons: it hard-drops non-RTP packets in `RtpPipeline::process()` (`src/protocol/rtp_pipeline.cpp:19`), and it only punches once after SETUP — the per-keepalive resend is commented out in both of its clients (`src/clients/rtsp_to_rtsp_client.cpp:977`, `src/clients/rtsp_to_http_client.cpp:177`). Its 20s vs our 30s keepalive period is not a factor. The **periodic punch is deliberately kept**: it maintains the media-path NAT mapping that #700's line needs. rtsproxy dropping it looks like a workaround for this same corruption rather than a protocol requirement. One caveat worth stating plainly: the *origin* of the stray datagram is inferred from code on both sides, not from a capture. This fix closes the injection path, so it holds regardless of the source — but if the reporter still sees a 30s cadence afterwards, the new `-v 4` log line (`Stream: Dropped N-byte datagram that is neither RTP nor MPEG-TS`) plus a `tcpdump -i any -n udp port <client_rtp_port> -X` will show what is really arriving. ## Test plan New `test_probe_echo_never_reaches_the_client` drives the real failure: `MockRTSPServerZTE` gains an `echo_probe_after` option that bounces the received 84-byte punch packet back onto the media port mid-stream, the way a ZTE server acks it. The test asserts the body contains no `ZXV10STB` bytes, that every 188-byte boundary still holds a `0x47` sync byte, and that the drop is logged. Verified non-vacuous: with `src/stream.c` reverted and the binary rebuilt, it fails on `assert b'ZXV10STB' not in body` — the punch bytes really are relayed into the TS stream today. - Full e2e suite: 564 passed, 8 skipped - `uv run ruff check e2e`: clean Refs #700. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
…S output (stackia#704) ## Summary A non-RTP datagram arriving on a media socket was forwarded to the client verbatim. The media sockets are unconnected, so anything can land in the same `recv()` as the media — in practice a ZTE `ZXV10STB` punch reply, which rtp2httpd solicits on every 30s keepalive since stackia#701. The 84-byte packet shifts the rest of the HTTP body off the 188-byte TS grid. ffmpeg-based players (aptv, VLC) resync on the next sync byte and show nothing; the embedded web player (mpegts.js) only probes for TS alignment at the *start* of a stream, so it stalls on "loading" and re-breaks every 30 seconds. Only bare MPEG-TS is legitimate on the non-RTP path (servers negotiating plain MP2T, raw TS multicast), so the pass-through is now gated on the existing `stream_payload_is_mpegts()` check and everything else is dropped with a debug log. ## Why the reporter saw what they saw Feedback on stackia#701: the web player stalls ~30s in while other clients are fine, and routing through [rtsproxy](https://github.com/plsy1/rtsproxy) fixes it. All three observations fall out of this bug: - **30s** is exactly `RTSP_KEEPALIVE_INTERVAL_MS`, when the punch is re-sent. - **Only the web player** breaks, because only it needs the byte stream to stay aligned mid-stream. - **rtsproxy is clean** for two independent reasons: it hard-drops non-RTP packets in `RtpPipeline::process()` (`src/protocol/rtp_pipeline.cpp:19`), and it only punches once after SETUP — the per-keepalive resend is commented out in both of its clients (`src/clients/rtsp_to_rtsp_client.cpp:977`, `src/clients/rtsp_to_http_client.cpp:177`). Its 20s vs our 30s keepalive period is not a factor. The **periodic punch is deliberately kept**: it maintains the media-path NAT mapping that stackia#700's line needs. rtsproxy dropping it looks like a workaround for this same corruption rather than a protocol requirement. One caveat worth stating plainly: the *origin* of the stray datagram is inferred from code on both sides, not from a capture. This fix closes the injection path, so it holds regardless of the source — but if the reporter still sees a 30s cadence afterwards, the new `-v 4` log line (`Stream: Dropped N-byte datagram that is neither RTP nor MPEG-TS`) plus a `tcpdump -i any -n udp port <client_rtp_port> -X` will show what is really arriving. ## Test plan New `test_probe_echo_never_reaches_the_client` drives the real failure: `MockRTSPServerZTE` gains an `echo_probe_after` option that bounces the received 84-byte punch packet back onto the media port mid-stream, the way a ZTE server acks it. The test asserts the body contains no `ZXV10STB` bytes, that every 188-byte boundary still holds a `0x47` sync byte, and that the drop is logged. Verified non-vacuous: with `src/stream.c` reverted and the binary rebuilt, it fails on `assert b'ZXV10STB' not in body` — the punch bytes really are relayed into the TS stream today. - Full e2e suite: 564 passed, 8 skipped - `uv run ruff check e2e`: clean Refs stackia#700. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Summary
Apply ZTE
ZXV10STBNAT traversal to every IPv4 upstream RTSP connection, with no configuration switch. For IPv4 control connections rtp2httpd now:x-NAT: <address>:<port>on DESCRIBE and SETUP.client_address=<address>andmode=PLAYto the UDP transport candidates in SETUP.ZXV10STBpacket once the server confirms UDP, keeping the existing 3x burst and per-keepalive retransmission.The advertised address and port are resolved in one place (
rtsp_nat_endpoint()): the STUN-discovered public mapping whenrtsp-stun-serveris configured and discovery succeeds, otherwise the RTSP control connection's own local endpoint (which followsr2h-ifname/upstream-interface-rtsp/upstream-interface/ routing table priority). RTP/RTCP sockets are pinned to that local address so the TCP and UDP source addresses stay aligned on multi-interface systems.Because DESCRIBE already carries
x-NAT, STUN discovery now has to settle before DESCRIBE is sent rather than before SETUP.Why
Issue #700 reports a Shandong Unicom line behind what looks like symmetric NAT, where no STUN server produces a usable mapping but rtsproxy's ZTE mode connects. The
ZXV10STBpacket is an authentication/punch datagram those operator networks expect on the RTP path; the wire format here is derived from that project and documented inline.This started out as an opt-in
rtsp-nat-mode = auto|stun|zteswitch. It was dropped in favour of always-on because the behaviour is additive rather than exclusive: the punch packet replaces only the payload of a probe that was already being sent, STUN still runs and now feeds the advertised endpoint instead of being bypassed, and the SETUP transport candidate list is unchanged. That leaves nothing a user would need to choose between.Refs #700.
Impact
x-NATandclient_addressadvertise, instead of onlyclient_port.source=address when the server provides one, falling back to the RTSP control peer. Previously no probe was sent at all whensource=was absent.Notable implementation details
AWAITING_OPTIONSmeans "OPTIONS sent, response not yet received", so resuming the state machine from the STUN handler on that state alone wrote OPTIONS and DESCRIBE into a single segment (CSeq 1 and 2 back to back). An explicitdescribe_waiting_for_stunflag now marks the parked state.stun_state_tnow retains the mapped IPv4 address, which was previously parsed only to be logged.config_reload()leaked the partially parsed bind address list on its failure path.Validation
pnpm run lint(biome, ruff, clang-format)Note:
test_rtsp_misc.py::TestRTSPTeardownTimeout::test_teardown_timeout_cleanupfailed once across several full-suite runs and did not reproduce afterwards. It does not touch the NAT/STUN path; likely pre-existing timing sensitivity, but not confirmed.