Skip to content

fix: serialize the RTSP handshake per client IP (same-NAT clients) - #441

Open
kmreisi wants to merge 1 commit into
games-on-whales:stablefrom
kmreisi:fix/multi-port-rtsp-udp
Open

fix: serialize the RTSP handshake per client IP (same-NAT clients)#441
kmreisi wants to merge 1 commit into
games-on-whales:stablefrom
kmreisi:fix/multi-port-rtsp-udp

Conversation

@kmreisi

@kmreisi kmreisi commented Jun 22, 2026

Copy link
Copy Markdown

Wolf gives each session a random fake IP in the launch response and expects the client to echo it back as the RTSP Host/URI — that's how it tells concurrent sessions apart. But below the high-quality-audio threshold moonlight-common-c sends Host: 0.0.0.0 and drops it, and there is no other per-session identifier in the RTSP handshake (the Session header is a constant). So two clients behind the same NAT both arrive as 0.0.0.0 from the same source IP, Wolf routes both to the first session, and their AES keys and ENET secrets get swapped — video corrupts and one client's input lands in the other's session.

Since the client can't reliably tell us which session it is, this couples each HTTPS launch/resume with the RTSP handshake that follows it: the launching client owns an exclusive RTSP window for its source IP, held until the handshake finishes (RTSP PLAY). While the window is open, ambiguous 0.0.0.0 packets from that IP resolve to that session; a concurrent same-IP launch waits until the window closes, so two handshakes never overlap.

client                                    Wolf
  |  HTTPS /launch (or /resume)             |
  |----------------------------------------→|  create session S
  |                                         |  open RTSP window:  srcIP → S
  |  200 (rtsp setup port)                  |
  |←----------------------------------------|
  |                                         |
  |  RTSP OPTIONS→DESCRIBE→SETUP(a/v/ctrl)   |
  |      →ANNOUNCE       (Host: 0.0.0.0)     |
  |----------------------------------------→|  window open → every packet → S
  |←----------------------------------------|  (SETUP control hands back S's ENET secret)
  |                                         |
  |  RTSP PLAY                              |
  |----------------------------------------→|  resolve → S, then close S's window
  |←----------------------------------------|
  |                                         |
  | === video/audio (UDP) + input (ENET), demuxed per session by that secret ===

A second distinct client on the same NAT that launches mid-handshake blocks at its own /launch until the first window closes, so the two handshakes are serialized and never resolve to each other.

Clients that do echo the fake IP (above the threshold) are still matched directly by it — the window only covers the 0.0.0.0 case.

@JBailes

JBailes commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Hey,

I've reviewed and tested this, but I am having trouble with this PR. In "moonlight.cpp:19-43" and "udp-ping.cpp:12-29", it clearly is demuxing multiple connections from the same IP via the secret payload. The only way this can occur is if the moonlight client is not correctly handling the secret payload. src/moonlight-server/rtp/udp-ping.cpp:30 is where this occurs, and the only way the described behavior would occur is from line 27 on udp-ping.cpp. But if that is occuring, the moonlight client is setup incorrectly.

I finished testing this myself and was able to get multiple connections via NAT on moonlight. I need more information on this.

@ABeltramo ABeltramo added the review done PR has been reviewed, waiting for contributor to address it label Jun 22, 2026
@kmreisi
kmreisi force-pushed the fix/multi-port-rtsp-udp branch from 955719a to 3878027 Compare June 22, 2026 16:56
@kmreisi

kmreisi commented Jun 22, 2026

Copy link
Copy Markdown
Author

Hi @JBailes, @ABeltramo,

You're right about the UDP layer — X-SS-Ping-Payload already takes care of demux there, so the UDP pool was unnecessary. Stripped it; PR is now RTSP-only and I've updated the title and description to match.

The RTSP side is a bit more subtle. In moonlight-common-c/src/RtspConnection.c:955-973, the client sets Host: "0.0.0.0" whenever any of these are true:

  • bitrate < 15 Mbps (HIGH_AUDIO_BITRATE_THRESHOLD)
  • CAPABILITY_SLOW_OPUS_DECODER is set
  • streaming remotely with > 2-channel surround

When that fires, Wolf's get_session() can't use rtsp_fake_ip for demux and falls back to source-IP, which matches both same-NAT clients to the first session. Easy to miss if the test bitrate is ≥ 15 Mbps stereo local — the client uses rtsp_fake_ip there and everything works. The repro is two Moonlight Android clients at 10 Mbps on the same NAT; you'll see EVP_DecryptFinal_ex failures on the second one without the fix.

Please have another look :-)

@kmreisi kmreisi changed the title fix: per-session RTSP+UDP port pool with IP-affinity allocation fix: per-session RTSP port pool for same-NAT Moonlight clients Jun 22, 2026
@kmreisi
kmreisi force-pushed the fix/multi-port-rtsp-udp branch 2 times, most recently from 73d89e6 to 12d9d96 Compare June 22, 2026 17:11
@ABeltramo

Copy link
Copy Markdown
Member

You are right about the moonlight code, also, looking at our code, it seems that some clients might not pass back that IP:

std::string ip; // Do not rely on this, can be missing (AndroidTV)

Other than the added complexity, my concern is that it's kind of a breaking change:

  • users that don't run it with network:host will have to change the configuration of ports exposed
  • and if we keep the old default of WOLF_RTSP_POOL_SIZE=1 it means that Wolf is broken for the cases that are highlighted in this PR.

There's an alternative model to this (which is what Sunshine uses IIRC) coupling HTTPS and RTSP. We know that after a launch/resume there's going to be a RTSP message. If we block all the concurrent HTTPS requests (with a reasonable low timeout) until that first RTSP message is received we can make sure that only the original client is attempting the RTSP connection and have a stronger guarantee of the pairing of the two.

Food for thoughts 😉

@kmreisi kmreisi changed the title fix: per-session RTSP port pool for same-NAT Moonlight clients fix: serialize the RTSP handshake per client IP (same-NAT clients) Jul 1, 2026
@kmreisi
kmreisi force-pushed the fix/multi-port-rtsp-udp branch from 12d9d96 to a93c0d8 Compare July 1, 2026 16:42
Two Moonlight clients behind the same NAT can both send RTSP with `Host:
0.0.0.0` (moonlight-common-c does this below the high-quality-audio threshold),
which makes Wolf's RTSP demux fall back to source-IP matching and route both to
whichever session is first in the list — swapping their AES keys and their ENET
`X-SS-Connect-Data` (so one client's input lands in the other's session).

Couple the HTTPS launch/resume with the RTSP handshake that follows it (the model
Sunshine uses): a launching client gets an exclusive RTSP window for its IP, and
a concurrent same-IP launch blocks until that window closes. While a window is
open, ambiguous `Host: 0.0.0.0` packets from that IP resolve to its session. The
window is held for the whole handshake and closed on the RTSP PLAY (with a safety
timeout), so no packet — including the SETUP that carries the ENET secret —
escapes to another session.

No new ports, no config; single-client and distinct-IP behaviour is unchanged.
@kmreisi
kmreisi force-pushed the fix/multi-port-rtsp-udp branch from a93c0d8 to 91c55f8 Compare July 1, 2026 17:21
@kmreisi kmreisi closed this Jul 1, 2026
@kmreisi kmreisi reopened this Jul 1, 2026
@kmreisi

kmreisi commented Jul 1, 2026

Copy link
Copy Markdown
Author

I clicked the wrong button, I just wanted to say that @ABeltramo's suggestion works great!

I updated the change accordingly, please have a look. :-)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

review done PR has been reviewed, waiting for contributor to address it

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants