Skip to content

feat(rtsp): always apply ZTE NAT traversal - #701

Merged
stackia merged 4 commits into
mainfrom
codex/rtsp-zte-nat
Aug 6, 2026
Merged

feat(rtsp): always apply ZTE NAT traversal#701
stackia merged 4 commits into
mainfrom
codex/rtsp-zte-nat

Conversation

@stackia

@stackia stackia commented Aug 3, 2026

Copy link
Copy Markdown
Owner

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 #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 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 #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.

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Documentation preview

The documentation preview has been deployed for this pull request.

@stackia
stackia marked this pull request as ready for review August 3, 2026 10:57
@stackia
stackia requested a review from Copilot August 3, 2026 10:57

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 via config_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.

Comment thread rtp2httpd.conf Outdated
Comment thread docs/reference/configuration.md Outdated
Comment thread docs/en/reference/configuration.md Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread ikuai-support/rtp2httpd/scripts/start.sh Outdated
Comment thread ikuai-support/rtp2httpd/app/option.json Outdated
Comment thread e2e/helpers/mock_rtsp.py Outdated
Comment thread src/configuration.c Outdated
stackia added 2 commits August 3, 2026 19:18
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.
@stackia stackia changed the title feat(rtsp): add ZTE NAT traversal mode feat(rtsp): always apply ZTE NAT traversal Aug 6, 2026
@stackia
stackia merged commit 31f529d into main Aug 6, 2026
11 checks passed
@stackia
stackia deleted the codex/rtsp-zte-nat branch August 6, 2026 11:25
Tubetrue01 pushed a commit to Tubetrue01/rtp2httpd that referenced this pull request Aug 7, 2026
## 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.
stackia added a commit that referenced this pull request Aug 7, 2026
…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)
Tubetrue01 pushed a commit to Tubetrue01/rtp2httpd that referenced this pull request Aug 8, 2026
…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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants