fix(webrtc): fix GStreamer WHEP interop (#4720)

## Summary

This PR fixes GStreamer interoperability issues during WebRTC/WHEP
negotiation with ZLMediaServer.

GStreamer could fail to establish the connection for two separate
reasons:

1. ZLMediaServer generated a non-compliant ICE `ufrag`. The generated
value contained `_`, which is not a valid ICE character, so GStreamer
rejected the SDP.
2. ZLMediaServer did not correctly handle `bundle-only` offers and could
answer an accepted bundled m-line with `port=0`, which caused the later
WHEP negotiation to fail.

## Changes

- Generate ICE `ufrag` values using ICE-compliant characters only.
- Preserve and handle `a=bundle-only` correctly during SDP parsing and
answer generation.
- Return `port=9` instead of `port=0` for accepted bundled m-lines.
- Add regression coverage for `bundle-only` SDP handling.
- URL-encode `delete_webrtc` query parameters in the returned `Location`
header so ICE-safe identifiers remain round-trippable over HTTP.

## Validation

- Built with WebRTC and SCTP enabled.
- Added regression test: `test_webrtc_regression`
- Verified:
  - ICE-safe identifier round-trip through `delete_webrtc`
  - `bundle-only` SDP answer generation
This commit is contained in:
Miau Lightouch
2026-04-21 11:37:08 +08:00
committed by GitHub
parent 56f2cfba7c
commit a9e0e1a81e
6 changed files with 165 additions and 27 deletions

View File

@@ -799,6 +799,7 @@ void RtcSession::loadFrom(const string &str) {
rtc_media.fingerprint = sdp.getItemClass<SdpAttrFingerprint>('a', "fingerprint");
}
rtc_media.ice_lite = media.getItem('a', "ice-lite").operator bool();
rtc_media.bundle_only = media.getItem('a', "bundle-only").operator bool();
auto ice_options = media.getItemClass<SdpAttrIceOption>('a', "ice-options");
rtc_media.ice_trickle = ice_options.trickle;
rtc_media.ice_renomination = ice_options.renomination;
@@ -1650,14 +1651,23 @@ static RtpDirection matchDirection(RtpDirection offer_direction, RtpDirection su
}
}
static DtlsRole mathDtlsRole(DtlsRole role) {
switch (role) {
case DtlsRole::actpass:
case DtlsRole::active: return DtlsRole::passive;
case DtlsRole::passive: return DtlsRole::active;
default: CHECK(0, "invalid role:", getDtlsRoleString(role)); return DtlsRole::passive;
}
}
static DtlsRole mathDtlsRole(DtlsRole role) {
switch (role) {
case DtlsRole::actpass:
case DtlsRole::active: return DtlsRole::passive;
case DtlsRole::passive: return DtlsRole::active;
default: CHECK(0, "invalid role:", getDtlsRoleString(role)); return DtlsRole::passive;
}
}
static uint16_t getAnswerMediaPort(const RtcMedia &offer_media) {
// RFC 8843: bundle-only m-lines may use port=0 in the offer but still need a
// real port placeholder in the accepted answer.
if (!offer_media.port && offer_media.bundle_only) {
return 9;
}
return offer_media.port;
}
void RtcConfigure::createMediaOffer(const std::shared_ptr<RtcSession> &ret) const {
int index = 0;
@@ -1824,13 +1834,14 @@ void RtcConfigure::matchMedia(const std::shared_ptr<RtcSession> &ret, const RtcM
RETRY:
if (offer_media.type == TrackApplication) {
RtcMedia answer_media = offer_media;
answer_media.role = mathDtlsRole(offer_media.role);
answer_media.ice_ufrag = configure.ice_ufrag;
answer_media.ice_pwd = configure.ice_pwd;
answer_media.fingerprint = configure.fingerprint;
answer_media.ice_lite = configure.ice_lite;
if (offer_media.type == TrackApplication) {
RtcMedia answer_media = offer_media;
answer_media.port = getAnswerMediaPort(offer_media);
answer_media.role = mathDtlsRole(offer_media.role);
answer_media.ice_ufrag = configure.ice_ufrag;
answer_media.ice_pwd = configure.ice_pwd;
answer_media.fingerprint = configure.fingerprint;
answer_media.ice_lite = configure.ice_lite;
#ifdef ENABLE_SCTP
answer_media.candidate = configure.candidate;
#else
@@ -1867,14 +1878,14 @@ RETRY:
// All codecs for this media in the offer are not supported
continue;
}
RtcMedia answer_media;
answer_media.type = offer_media.type;
answer_media.mid = offer_media.mid;
answer_media.proto = offer_media.proto;
answer_media.port = offer_media.port;
answer_media.addr = offer_media.addr;
answer_media.bandwidth = offer_media.bandwidth;
answer_media.rtcp_addr = offer_media.rtcp_addr;
RtcMedia answer_media;
answer_media.type = offer_media.type;
answer_media.mid = offer_media.mid;
answer_media.proto = offer_media.proto;
answer_media.port = getAnswerMediaPort(offer_media);
answer_media.addr = offer_media.addr;
answer_media.bandwidth = offer_media.bandwidth;
answer_media.rtcp_addr = offer_media.rtcp_addr;
answer_media.rtcp_mux = offer_media.rtcp_mux && configure.rtcp_mux;
answer_media.rtcp_rsize = offer_media.rtcp_rsize && configure.rtcp_rsize;
answer_media.ice_trickle = offer_media.ice_trickle && configure.ice_trickle;

View File

@@ -652,6 +652,9 @@ public:
bool rtcp_rsize { false };
SdpAttrRtcp rtcp_addr;
//////// bundle ////////
bool bundle_only { false };
//////// ice ////////
bool ice_trickle { false };
bool ice_lite { false };

View File

@@ -137,7 +137,12 @@ static std::string getServerPrefix() {
// 拷贝tcp端口 [AUTO-TRANSLATED:23191878]
// Copy tcp port
memcpy(buf + 6, &(reinterpret_cast<sockaddr_in *>(&addr)->sin_port), 2);
auto ret = encodeBase64(string(buf, 8)) + '_';
// RFC 5245 §15.4: ice-char = ALPHA / DIGIT / "+" / "/"
auto ret = encodeBase64(string(buf, 8));
// Remove base64 '=' padding (not a valid ice-char)
ret.erase(std::remove(ret.begin(), ret.end(), '='), ret.end());
// Use '/' separator instead of '_' (not a valid ice-char)
ret += '/';
InfoL << "MediaServer(" << host << ":" << udp_port << ":" << tcp_port << ") prefix: " << ret;
return ret;
}