Commit 85de5156d5 for asterisk.org

commit 85de5156d5111a962ae3585ca76b6e77b9e5a006
Author: Mehrdad Seifzadeh <mehrdad.seifzadeh@gmail.com>
Date:   Mon Jul 6 15:29:12 2026 +0330

    res_pjsip: Accept zero-port BUNDLE-only media offers

diff --git a/res/res_pjsip_session.c b/res/res_pjsip_session.c
index 53dcbbfe0e..473b498c36 100644
--- a/res/res_pjsip_session.c
+++ b/res/res_pjsip_session.c
@@ -770,6 +770,21 @@ static void remove_stream_from_bundle(struct ast_sip_session_media *session_medi
 	session_media->bundled = 0;
 }

+/*!
+ * \brief Determine whether a zero-port stream is offered for BUNDLE-only use.
+ *
+ * RFC 9143 permits an offerer to use a zero port together with the
+ * bundle-only attribute when the stream is to be accepted only as part of
+ * the associated BUNDLE group. A zero port without these conditions still
+ * means that the stream is disabled.
+ */
+static int is_bundle_only_stream(const struct ast_sip_session_media *session_media,
+	const struct pjmedia_sdp_media *stream)
+{
+	return !stream->desc.port && session_media->bundled
+		&& pjmedia_sdp_media_find_attr2(stream, "bundle-only", NULL);
+}
+
 static int handle_incoming_sdp(struct ast_sip_session *session, const pjmedia_sdp_session *sdp)
 {
 	int i;
@@ -798,6 +813,7 @@ static int handle_incoming_sdp(struct ast_sip_session *session, const pjmedia_sd
 		RAII_VAR(struct sdp_handler_list *, handler_list, NULL, ao2_cleanup);
 		struct ast_sip_session_media *session_media = NULL;
 		int res;
+		int bundle_only;
 		enum ast_media_type type;
 		struct ast_stream *stream = NULL;
 		pjmedia_sdp_media *remote_stream = sdp->media[i];
@@ -892,14 +908,25 @@ static int handle_incoming_sdp(struct ast_sip_session *session, const pjmedia_sd
 				 ast_sip_session_get_name(session));
 		}

-		/* If this stream is already declined mark it as such, or mark it as such if we've reached the limit */
-		if (!remote_stream->desc.port || is_stream_limitation_reached(type, session->endpoint, type_streams)) {
+		/* MID and BUNDLE membership must be known before interpreting a zero port. */
+		set_mid_and_bundle_group(session, session_media, sdp, remote_stream);
+		bundle_only = is_bundle_only_stream(session_media, remote_stream);
+
+		/* A zero port normally declines a stream. RFC 9143 defines an exception
+		 * for a bundled stream carrying the bundle-only attribute.
+		 */
+		if ((!remote_stream->desc.port && !bundle_only)
+			|| is_stream_limitation_reached(type, session->endpoint, type_streams)) {
 			remove_stream_from_bundle(session_media, stream);
 			SCOPE_EXIT_EXPR(continue, "%s: Declining incoming SDP media stream %s'\n",
 				ast_sip_session_get_name(session), ast_str_tmp(128, ast_stream_to_str(stream, &STR_TMP)));
 		}

-		set_mid_and_bundle_group(session, session_media, sdp, remote_stream);
+		if (bundle_only) {
+			ast_trace(-1, "%s: Accepting zero-port bundle-only SDP media stream %s\n",
+				ast_sip_session_get_name(session), ast_str_tmp(128, ast_stream_to_str(stream, &STR_TMP)));
+		}
+
 		set_remote_mslabel_and_stream_group(session, session_media, sdp, remote_stream, stream);

 		if (session_media->handler) {
@@ -1132,7 +1159,15 @@ static int handle_negotiated_sdp(struct ast_sip_session *session, const pjmedia_
 		 * the state to REMOVED, then our work here is done, so go ahead and move on
 		 * to the next stream.
 		 */
-		if (!remote->media[i]->desc.port) {
+		/*
+		 * A zero port in a remote answer declines the stream.  In an offer,
+		 * however, RFC 9143 permits a bundled stream to use port zero together
+		 * with a=bundle-only.  In that case, keep the stream if our local answer
+		 * accepted it with a non-zero port.
+		 */
+		if (!local->media[i]->desc.port
+			|| (!remote->media[i]->desc.port
+				&& !is_bundle_only_stream(session_media, remote->media[i]))) {
 			ast_stream_set_state(stream, AST_STREAM_STATE_REMOVED);
 			continue;
 		}
diff --git a/third-party/pjproject/patches/0030-pjmedia-handle-bundle-only-offers.patch b/third-party/pjproject/patches/0030-pjmedia-handle-bundle-only-offers.patch
new file mode 100644
index 0000000000..dc29b272da
--- /dev/null
+++ b/third-party/pjproject/patches/0030-pjmedia-handle-bundle-only-offers.patch
@@ -0,0 +1,253 @@
+From 74ae132492b3a7ce1c5ece28096173e878c7ca48 Mon Sep 17 00:00:00 2001
+From: Mehrdad Seifzadeh <mehrdad.seifzadeh@gmail.com>
+Date: Mon, 13 Jul 2026 12:26:45 +0330
+Subject: [PATCH] pjmedia: Handle zero-port bundle-only offers (#5067)
+
+(cherry picked from commit 58666750d6bfe19dc213d010be63b1ab6b33ef65)
+---
+ pjmedia/src/pjmedia/sdp_neg.c   | 90 ++++++++++++++++++++++++++++++--
+ pjmedia/src/test/sdp_neg_test.c | 91 +++++++++++++++++++++++++++++++++
+ 2 files changed, 178 insertions(+), 3 deletions(-)
+
+diff --git a/pjmedia/src/pjmedia/sdp_neg.c b/pjmedia/src/pjmedia/sdp_neg.c
+index 66682cc..713ea2f 100644
+--- a/pjmedia/src/pjmedia/sdp_neg.c
++++ b/pjmedia/src/pjmedia/sdp_neg.c
+@@ -1242,10 +1242,89 @@ static void apply_answer_symmetric_pt(pj_pool_t *pool,
+ }
+
+
++/* Return PJ_TRUE if a session-level group attribute contains the supplied MID
++ * in a BUNDLE group.
++ */
++static pj_bool_t bundle_group_contains_mid(const pjmedia_sdp_session *sdp,
++                                           const pj_str_t *mid)
++{
++    unsigned i;
++
++    for (i = 0; i < sdp->attr_count; ++i) {
++        const pjmedia_sdp_attr *attr = sdp->attr[i];
++        const char *pos, *end;
++        unsigned token_index = 0;
++        pj_bool_t is_bundle = PJ_FALSE;
++
++        if (pj_stricmp2(&attr->name, "group") != 0)
++            continue;
++
++        if (attr->value.ptr == NULL || attr->value.slen == 0)
++            continue;
++
++        pos = attr->value.ptr;
++        end = attr->value.ptr + attr->value.slen;
++        while (pos < end) {
++            const char *token_start;
++            pj_str_t token;
++
++            while (pos < end && (*pos == ' ' || *pos == '\t'))
++                ++pos;
++            if (pos == end)
++                break;
++
++            token_start = pos;
++            while (pos < end && *pos != ' ' && *pos != '\t')
++                ++pos;
++
++            token.ptr = (char *)token_start;
++            token.slen = pos - token_start;
++
++            if (token_index++ == 0) {
++                is_bundle = (pj_stricmp2(&token, "BUNDLE") == 0);
++                if (!is_bundle)
++                    break;
++                continue;
++            }
++
++            if (is_bundle && pj_strcmp(&token, mid) == 0)
++                return PJ_TRUE;
++        }
++    }
++
++    return PJ_FALSE;
++}
++
++
++/* RFC 9143 allows a bundled m= section in an offer to use port zero when
++ * a=bundle-only is present.  The exception only applies when the section has
++ * a MID and that MID is actually listed in a session-level group:BUNDLE
++ * attribute.
++ */
++static pj_bool_t is_bundle_only_offer(const pjmedia_sdp_session *sdp,
++                                      const pjmedia_sdp_media *media)
++{
++    const pjmedia_sdp_attr *mid;
++
++    if (media->desc.port != 0 ||
++        !pjmedia_sdp_media_find_attr2(media, "bundle-only", NULL))
++    {
++        return PJ_FALSE;
++    }
++
++    mid = pjmedia_sdp_media_find_attr2(media, "mid", NULL);
++    if (!mid)
++        return PJ_FALSE;
++
++    return bundle_group_contains_mid(sdp, &mid->value);
++}
++
++
+ /* Try to match offer with answer. */
+ static pj_status_t match_offer(pj_pool_t *pool,
+                                pj_bool_t prefer_remote_codec_order,
+                                pj_bool_t answer_with_multiple_codecs,
++                               pj_bool_t bundle_only,
+                                const pjmedia_sdp_media *offer,
+                                const pjmedia_sdp_media *preanswer,
+                                const pjmedia_sdp_session *preanswer_sdp,
+@@ -1267,8 +1346,11 @@ static pj_status_t match_offer(pj_pool_t *pool,
+     unsigned nclockrate = 0, clockrate[PJMEDIA_MAX_SDP_FMT];
+     unsigned ntel_clockrate = 0, tel_clockrate[PJMEDIA_MAX_SDP_FMT];
+
+-    /* If offer has zero port, just clone the offer */
+-    if (offer->desc.port == 0) {
++    /* A zero port normally disables an offered media section. RFC 9143
++     * defines an exception for a valid bundle-only section, which must be
++     * answered using the BUNDLE transport rather than being rejected.
++     */
++    if (offer->desc.port == 0 && !bundle_only) {
+         answer = sdp_media_clone_deactivate(pool, offer, preanswer,
+                                             preanswer_sdp);
+         *p_answer = answer;
+@@ -1709,10 +1791,12 @@ static pj_status_t create_answer( pj_pool_t *pool,
+         const pjmedia_sdp_media *om;    /* offer */
+         const pjmedia_sdp_media *im;    /* initial media */
+         pjmedia_sdp_media *am = NULL;   /* answer/result */
++        pj_bool_t bundle_only;
+         pj_uint32_t om_tp;
+         unsigned j;
+
+         om = offer->media[i];
++        bundle_only = is_bundle_only_offer(offer, om);
+
+         om_tp = pjmedia_sdp_transport_get_proto(&om->desc.transport);
+         PJMEDIA_TP_PROTO_TRIM_FLAG(om_tp, PJMEDIA_TP_PROFILE_RTCP_FB);
+@@ -1737,7 +1821,7 @@ static pj_status_t create_answer( pj_pool_t *pool,
+
+                 /* See if it has matching codec. */
+                 status2 = match_offer(pool, prefer_remote_codec_order,
+-                                      answer_with_multiple_codecs,
++                                      answer_with_multiple_codecs, bundle_only,
+                                       om, im, initial, &am);
+                 if (status2 == PJ_SUCCESS) {
+                     /* Mark media as used. */
+diff --git a/pjmedia/src/test/sdp_neg_test.c b/pjmedia/src/test/sdp_neg_test.c
+index 3cf8d52..e5326b5 100644
+--- a/pjmedia/src/test/sdp_neg_test.c
++++ b/pjmedia/src/test/sdp_neg_test.c
+@@ -1652,6 +1652,82 @@ static int perform_test(pj_pool_t *pool, int test_index)
+     return 0;
+ }
+
++/* RFC 9143: an offered media section using port zero is not rejected when
++ * it has a=bundle-only and its MID belongs to the BUNDLE group.
++ */
++static int sdp_neg_bundle_only_test(pj_pool_t *pool)
++{
++    static char offer_str[] =
++        "v=0\r\n"
++        "o=- 0 0 IN IP4 127.0.0.1\r\n"
++        "s=-\r\n"
++        "c=IN IP4 127.0.0.1\r\n"
++        "t=0 0\r\n"
++        "a=group:BUNDLE 0 1\r\n"
++        "m=audio 4000 RTP/AVP 0\r\n"
++        "a=mid:0\r\n"
++        "m=video 0 RTP/AVP 96\r\n"
++        "a=mid:1\r\n"
++        "a=bundle-only\r\n"
++        "a=rtpmap:96 VP8/90000\r\n";
++
++    static char local_str[] =
++        "v=0\r\n"
++        "o=- 1 1 IN IP4 127.0.0.1\r\n"
++        "s=-\r\n"
++        "c=IN IP4 127.0.0.1\r\n"
++        "t=0 0\r\n"
++        "a=group:BUNDLE 0 1\r\n"
++        "m=audio 5000 RTP/AVP 0\r\n"
++        "a=mid:0\r\n"
++        "m=video 5002 RTP/AVP 96\r\n"
++        "a=mid:1\r\n"
++        "a=rtpmap:96 VP8/90000\r\n";
++
++    pjmedia_sdp_session *offer, *local;
++    pjmedia_sdp_neg *neg;
++    const pjmedia_sdp_session *active_local;
++    pj_status_t status;
++    char b1[sizeof(offer_str)], b2[sizeof(local_str)];
++
++    pj_memcpy(b1, offer_str, sizeof(offer_str));
++    pj_memcpy(b2, local_str, sizeof(local_str));
++
++    if (pjmedia_sdp_parse(pool, b1, pj_ansi_strlen(b1), &offer) !=
++        PJ_SUCCESS)
++    {
++        return -3000;
++    }
++
++    if (pjmedia_sdp_parse(pool, b2, pj_ansi_strlen(b2), &local) !=
++        PJ_SUCCESS)
++    {
++        return -3010;
++    }
++
++    if (pjmedia_sdp_neg_create_w_remote_offer(pool, local, offer, &neg) !=
++        PJ_SUCCESS)
++    {
++        return -3020;
++    }
++
++    status = pjmedia_sdp_neg_negotiate(pool, neg, 0);
++    if (status != PJ_SUCCESS) {
++        app_perror(status, "   sdp_neg_bundle_only_test: negotiate failed");
++        return -3030;
++    }
++
++    pjmedia_sdp_neg_get_active_local(neg, &active_local);
++
++    if (active_local->media_count != 2)
++        return -3040;
++
++    if (active_local->media[1]->desc.port == 0)
++        return -3050;
++
++    return 0;
++}
++
+ int sdp_neg_test()
+ {
+     unsigned i;
+@@ -1674,6 +1750,21 @@ int sdp_neg_test()
+         }
+     }
+
++    {
++        pj_pool_t *pool;
++
++        pool = pj_pool_create(mem, "sdp_neg_bundle", 4000, 4000, NULL);
++        if (!pool)
++            return PJ_ENOMEM;
++
++        PJ_LOG(3,(THIS_FILE, "  sdp_neg_bundle_only_test"));
++        status = sdp_neg_bundle_only_test(pool);
++        pj_pool_release(pool);
++
++        if (status != 0)
++            return status;
++    }
++
+     return 0;
+ }
+
+--
+2.47.3
+